diff --git a/.github/workflows/ci-bcf-pypi.yml b/.github/workflows/ci-bcf-pypi.yml index 4b70c77e0a..75034acc2f 100644 --- a/.github/workflows/ci-bcf-pypi.yml +++ b/.github/workflows/ci-bcf-pypi.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v2 # https://github.com/actions/checkout - uses: actions/setup-python@v2 # https://github.com/actions/setup-python with: - python-version: '3.10' # Version range or exact version of a Python version to use, using SemVer's version range syntax + python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - run: echo ${{ env.DATE }} - name: Get current date diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index a0e5640c2c..fa0a892a6b 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -142,6 +142,12 @@ jobs: sverchok_zip="$(pwd)/dist/$(ls dist)" blender --command extension install-file -r user_default $sverchok_zip + # Install Sun Position extension. + blender --online-mode --command extension sync + blender --online-mode --background --python-expr "import bpy; \ + bpy.ops.extensions.package_install(repo_index=0, pkg_id='sun_position'); \ + bpy.ops.preferences.addon_enable(module='bl_ext.blender_org.sun_position'); bpy.ops.wm.save_userpref()" + cd ../bonsai pip install pytest-blender blender --background --python scripts/setup_pytest.py diff --git a/.github/workflows/ci-bonsai.yml b/.github/workflows/ci-bonsai.yml index 362f8ecbda..1bf217da89 100644 --- a/.github/workflows/ci-bonsai.yml +++ b/.github/workflows/ci-bonsai.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py310, py311, py312] + pyver: [py311, py312] config: - { name: "Windows Build", @@ -32,15 +32,15 @@ jobs: } - { name: "Linux Build", - short_name: linux + short_name: linux, } - { name: "MacOS Build", - short_name: macos + short_name: macos, } - { name: "MacOS ARM Build", - short_name: macosm1 + short_name: macosm1, } steps: - uses: actions/checkout@v2 @@ -53,8 +53,7 @@ jobs: run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT - name: Compile run: | - cd src/bonsai && - make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }} IS_STABLE=TRUE + cd src/bonsai && make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }} IS_STABLE=TRUE - name: Find zip file name id: find_zip run: | diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml index ab2a1d0245..ae8142375b 100644 --- a/.github/workflows/ci-ifcopenshell-docker.yml +++ b/.github/workflows/ci-ifcopenshell-docker.yml @@ -93,7 +93,7 @@ jobs: lfs: true - name: Download - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4.1.7 with: # Artifact name name: ifcos-artifacts diff --git a/README.md b/README.md index 626c66f0f8..935cd7e94d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ is implemented for the IFC releases [IFC2x3 TC1] and [IFC4 Add2 TC1]. Extending is possible at compile-time when using C++ and at run-time when using Python. In addition to a C++ and Python API, IfcOpenShell comes with an ecosystem of tools, notably including IfcConvert (an application -to convert IFC models to other formats), the BlenderBIM Add-on (an add-on to Blender providing a graphical IFC authoring platform), +to convert IFC models to other formats), Bonsai (an add-on to Blender providing a graphical IFC authoring platform), and many other libraries, CLI apps, and more. Support is also provided for auxiliary standards such as BCF and IDS. For more information, see: @@ -24,8 +24,8 @@ For more information, see: * [IfcOpenShell Python Hello World Tutorial](https://docs.ifcopenshell.org/ifcopenshell-python/hello_world.html) * [Bonsai Website](https://bonsaibim.org) * [Bonsai Documentation](https://docs.bonsaibim.org/index.html) - * [Add-on Installation](https://docs.bonsaibim.org/users/installation.html) - * [Exploring an IFC model](https://docs.bonsaibim.org/users/exploring_an_ifc_model.html) + * [Add-on Installation](https://docs.bonsaibim.org/quickstart/installation.html) + * [Exploring an IFC model](https://docs.bonsaibim.org/quickstart/explore_model.html) Development is sponsored through your generous donations! @@ -37,7 +37,7 @@ Contents | Name | Description | License | Service | | ------------------------- | --------------------------------------------------------------------- | ------------------- | ------- | | bcf | Library to read and write BCF-XML and query OpenCDE BCF-API modules | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/bcf-client?label=PyPI&color=006dad)](https://pypi.org/project/bcf-client/) | -| blenderbim | Add-on to Blender providing a graphical native IFC authoring platform | GPL-3.0-or-later | [![Official](https://img.shields.io/badge/BonsaiBIM.org-Download-70ba35)](https://bonsaibim.org/download.html) [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=blenderbim-*&label=GitHub-Unstable&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=blenderbim&expanded=true) [![Chocolatey](https://img.shields.io/chocolatey/v/blenderbim-nightly?label=Chocolatey&color=5c9fd8)](https://community.chocolatey.org/packages/blenderbim-nightly/) | +| bonsai | Add-on to Blender providing a graphical native IFC authoring platform | GPL-3.0-or-later | [![Official](https://img.shields.io/badge/BonsaiBIM.org-Download-70ba35)](https://bonsaibim.org/download.html) [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=bonsai-*&label=GitHub-Unstable&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=bonsai&expanded=true) [![Chocolatey](https://img.shields.io/chocolatey/v/blenderbim-nightly?label=Chocolatey&color=5c9fd8)](https://community.chocolatey.org/packages/blenderbim-nightly/) | | bsdd | Library to query the bSDD API | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/bsdd?label=PyPI&color=006dad)](https://pypi.org/project/bsdd/) | | ifc2ca | Utility to convert IFC structural analysis models to Code_Aster | LGPL-3.0-or-later | | ifc4d | Convert to and from IFC and project management software | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifc4d?label=PyPI&color=006dad)](https://pypi.org/project/ifc4d/) | diff --git a/VERSION b/VERSION index b4d6d12101..6f4eebdf6f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.11 +0.8.1 diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 1990faffef..75d7fe5fa6 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -29,7 +29,7 @@ endif() cmake_policy(SET CMP0048 NEW) cmake_policy(SET CMP0074 NEW) -cmake_policy(SET CMP0078 OLD) +cmake_policy(SET CMP0078 NEW) cmake_policy(SET CMP0086 NEW) if (POLICY CMP0144) cmake_policy(SET CMP0144 NEW) # find_package() uses upper-case _ROOT variables. @@ -730,6 +730,10 @@ if(MSVC) # endif() add_definitions(-D_ENABLE_EXTENDED_ALIGNED_STORAGE) + # See #5158. + if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19.40) + add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR) + endif() else() add_definitions(-Wall -Wextra) diff --git a/nix/build-all.py b/nix/build-all.py index 09773f5e84..95788ce939 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -81,7 +81,7 @@ ADD_COMMIT_SHA = os.getenv("ADD_COMMIT_SHA") PYTHON_VERSIONS = ["3.9.11", "3.10.3", "3.11.8", "3.12.1"] JSON_VERSION = "v3.6.1" OCE_VERSION = "0.18.3" -OCCT_VERSION = "7.5.3" +OCCT_VERSION = "7.8.1" BOOST_VERSION = "1.80.0" PCRE_VERSION = "8.41" LIBXML2_VERSION = "2.9.11" @@ -351,6 +351,8 @@ def git_clone_or_pull_repository(clone_url, target_dir, revision=None): run([git, "pull", clone_url], cwd=target_dir) if revision != None: + run([git, "reset", "--hard"], cwd=target_dir) + run([git, "fetch", "--all"], cwd=target_dir) run([git, "checkout", revision], cwd=target_dir) @@ -588,6 +590,12 @@ if USE_OCCT and "occ" in targets: if OCCT_VERSION == "7.7.1": patches.append("./patches/occt/no_ExpToCasExe.patch") + if OCCT_VERSION == "7.7.2": + patches.append("./patches/occt/no_ExpToCasExe_7_7_2.patch") + + if OCCT_VERSION == "7.8.1": + patches.append("./patches/occt/no_ExpToCasExe_7_8_1.patch") + if "wasm" in flags: patches.append("./patches/occt/no_em_js.patch") @@ -963,7 +971,7 @@ if "IfcOpenShell-Python" in targets: logger.info(f"\rBuilding python {python_version} wrapper... ") - run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "_ifcopenshell_wrapper"], cwd=python_dir) + run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper"], cwd=python_dir) run([make, "install/local"], cwd=os.path.join(python_dir, "ifcwrap")) if python_executable: diff --git a/nix/patches/occt/no_ExpToCasExe_7_7_2.patch b/nix/patches/occt/no_ExpToCasExe_7_7_2.patch new file mode 100644 index 0000000000..8b9f924e83 --- /dev/null +++ b/nix/patches/occt/no_ExpToCasExe_7_7_2.patch @@ -0,0 +1,13 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 1bacca1a48..11f931ad39 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -820,6 +820,8 @@ else() + OCCT_CHECK_AND_UNSET ("3RDPARTY_DOT_EXECUTABLE") + endif() + ++list (REMOVE_ITEM BUILD_TOOLKITS ExpToCasExe) ++ + # bison + if (BUILD_YACCLEX) + OCCT_INCLUDE_CMAKE_FILE ("adm/cmake/bison") diff --git a/nix/patches/occt/no_ExpToCasExe_7_8_1.patch b/nix/patches/occt/no_ExpToCasExe_7_8_1.patch new file mode 100644 index 0000000000..63d6fd3206 --- /dev/null +++ b/nix/patches/occt/no_ExpToCasExe_7_8_1.patch @@ -0,0 +1,13 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 86905287dc..9d0bce984c 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -828,6 +828,8 @@ else() + OCCT_CHECK_AND_UNSET ("3RDPARTY_DOT_EXECUTABLE") + endif() + ++list (REMOVE_ITEM BUILD_TOOLKITS ExpToCasExe) ++ + # bison + if (BUILD_YACCLEX) + OCCT_INCLUDE_CMAKE_FILE ("adm/cmake/bison") diff --git a/pyproject.toml b/pyproject.toml index 8e329ff191..88fc4867e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ include = ''' src/( bcf |bcfserver - |blenderbim + |bonsai |bsdd |foundationserver |ifc2ca diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index af92e11dc1..110ab39d83 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -66,7 +66,11 @@ BLENDER_PLATFORM:=linux-x64 endif ifeq ($(PLATFORM), macos) +ifeq ($(PYVERSION), py311) PYPI_PLATFORM:=--platform macosx_10_10_x86_64 +else +PYPI_PLATFORM:=--platform macosx_10_13_x86_64 +endif BLENDER_PLATFORM:=macos-x64 endif @@ -81,9 +85,12 @@ BLENDER_PLATFORM:=windows-x64 endif # Current build commit hash. -OLD:=d51fa2c +OLD:=a51ed6e .PHONY: bump bump: +ifndef NEW + $(error ERROR: 'NEW' variable is not set (should be set with new commmit hash). Example use for 'bump' command: 'make bump NEW=0123456'.) +endif cd . && $(SED) -b "s/$(OLD)/$(NEW)/" Makefile cd ../ifcopenshell-python/ && $(SED) -b "s/$(OLD)/$(NEW)/" Makefile @@ -278,7 +285,7 @@ ifeq ($(PLATFORM), macosm1) mv "$$prev_whl_name" "$$whl_name"; prev_whl_name=$$(find build/bonsai/wheels/tzfpy-*.whl); \ - whl_name=$$(echo $$prev_whl_name | sed "s/macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2/macosx_10_12_arm64/"); \ + whl_name=$$(echo $$prev_whl_name | sed -E "s/macosx_10_([0-9]+)_x86_64\.macosx_11_0_arm64\.macosx_10_\1_universal2/macosx_10_\1_arm64/"); \ mv "$$prev_whl_name" "$$whl_name"; # Has only universal build. @@ -288,7 +295,7 @@ ifeq ($(PLATFORM), macosm1) endif ifeq ($(PLATFORM), macos) prev_whl_name=$$(find build/bonsai/wheels/tzfpy-*.whl); \ - whl_name=$$(echo $$prev_whl_name | sed "s/macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2/macosx_10_9_x86_64/"); \ + whl_name=$$(echo $$prev_whl_name | sed -E "s/macosx_10_([0-9]+)_x86_64\.macosx_11_0_arm64\.macosx_10_\1_universal2/macosx_10_\1_x86_64/"); \ mv "$$prev_whl_name" "$$whl_name"; prev_whl_name=$$(find build/bonsai/wheels/greenlet-*.whl); \ diff --git a/src/bonsai/bonsai/__init__.py b/src/bonsai/bonsai/__init__.py index dce3b696ef..54c17a1c6c 100644 --- a/src/bonsai/bonsai/__init__.py +++ b/src/bonsai/bonsai/__init__.py @@ -56,7 +56,7 @@ bbim_semver: dict[str, Any] = {} # Accessed from bonsai dependency: last_error = None -last_actions: deque = deque(maxlen=10) +last_actions: deque = deque(maxlen=20) FIRST_INSTALLED_BBIM_VERSION: Union[str, None] = None REINSTALLED_BBIM_VERSION: Union[str, None] = None REGISTERED_BBIM_PACKAGE: str @@ -329,7 +329,7 @@ if IN_BLENDER: layout.operator("bim.copy_debug_information", text="Copy Error Message To Clipboard") op = layout.operator("bim.open_uri", text="How Can I Fix This?") - op.uri = "https://docs.bonsaibim.org/users/troubleshooting.html#installation-issues" + op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#installation-issues" layout.label(text="Try Reinstalling:", icon="IMPORT") op = layout.operator("bim.open_uri", text="Re-download Add-on") diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 546cd52b05..4a60f49845 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -144,7 +144,7 @@ classes = [ # Project overview ui.BIM_PT_tab_new_project_wizard, ui.BIM_PT_tab_project_info, - ui.BIM_PT_tab_spatial_decomposition, + ui.BIM_PT_tab_spatial, ui.BIM_PT_tab_project_setup, ui.BIM_PT_tab_geometry, ui.BIM_PT_tab_stakeholders, diff --git a/src/bonsai/bonsai/bim/data/cache/shapes/.gitignore b/src/bonsai/bonsai/bim/data/cache/.gitignore similarity index 56% rename from src/bonsai/bonsai/bim/data/cache/shapes/.gitignore rename to src/bonsai/bonsai/bim/data/cache/.gitignore index d6b7ef32c8..7c9d611b59 100644 --- a/src/bonsai/bonsai/bim/data/cache/shapes/.gitignore +++ b/src/bonsai/bonsai/bim/data/cache/.gitignore @@ -1,2 +1,3 @@ * !.gitignore +!README.md diff --git a/src/bonsai/bonsai/bim/data/cache/README.md b/src/bonsai/bonsai/bim/data/cache/README.md new file mode 100644 index 0000000000..0a51fc4a12 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/cache/README.md @@ -0,0 +1 @@ +This cache folder contains .h5 files. These files cache IFC geometry for performance only. You may safely clear the contents of this cache folder without losing data. diff --git a/src/bonsai/bonsai/bim/data/icons/add.png b/src/bonsai/bonsai/bim/data/icons/add.png deleted file mode 100644 index b16b90a2a8..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/add.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/add_type.png b/src/bonsai/bonsai/bim/data/icons/add_type.png deleted file mode 100644 index a303cb0067..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/add_type.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/add_void.png b/src/bonsai/bonsai/bim/data/icons/add_void.png deleted file mode 100644 index 06e6b6859c..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/add_void.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/assign.png b/src/bonsai/bonsai/bim/data/icons/assign.png deleted file mode 100644 index 7195dc0b03..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/assign.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/butt.png b/src/bonsai/bonsai/bim/data/icons/butt.png deleted file mode 100644 index 3d3531175e..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/butt.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/centerline.png b/src/bonsai/bonsai/bim/data/icons/centerline.png deleted file mode 100644 index b75f93fff8..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/centerline.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/decomposition.png b/src/bonsai/bonsai/bim/data/icons/decomposition.png deleted file mode 100644 index b63af19882..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/decomposition.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_2-point_arc.png b/src/bonsai/bonsai/bim/data/icons/dm_2-point_arc.png new file mode 100644 index 0000000000..b694894b85 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_2-point_arc.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_3-point_arc.png b/src/bonsai/bonsai/bim/data/icons/dm_3-point_arc.png new file mode 100644 index 0000000000..2344f24851 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_3-point_arc.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_IFC.png b/src/bonsai/bonsai/bim/data/icons/dm_IFC.png new file mode 100644 index 0000000000..d2b6e399b3 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_IFC.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_add.png b/src/bonsai/bonsai/bim/data/icons/dm_add.png new file mode 100644 index 0000000000..7c1371fe4f Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_add.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_add_bend.png b/src/bonsai/bonsai/bim/data/icons/dm_add_bend.png new file mode 100644 index 0000000000..0483124069 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_add_bend.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_add_transition.png b/src/bonsai/bonsai/bim/data/icons/dm_add_transition.png new file mode 100644 index 0000000000..6f020d1dfa Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_add_transition.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_add_type.png b/src/bonsai/bonsai/bim/data/icons/dm_add_type.png new file mode 100644 index 0000000000..899154bf95 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_add_type.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_add_void.png b/src/bonsai/bonsai/bim/data/icons/dm_add_void.png new file mode 100644 index 0000000000..a1c0df84a3 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_add_void.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_apply_axis.png b/src/bonsai/bonsai/bim/data/icons/dm_apply_axis.png new file mode 100644 index 0000000000..f77dd35c26 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_apply_axis.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_apply_profile.png b/src/bonsai/bonsai/bim/data/icons/dm_apply_profile.png new file mode 100644 index 0000000000..2d3a40e104 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_apply_profile.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_apply_void.png b/src/bonsai/bonsai/bim/data/icons/dm_apply_void.png new file mode 100644 index 0000000000..b358f03786 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_apply_void.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_assign.png b/src/bonsai/bonsai/bim/data/icons/dm_assign.png new file mode 100644 index 0000000000..cdf007a958 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_assign.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_butt.png b/src/bonsai/bonsai/bim/data/icons/dm_butt.png new file mode 100644 index 0000000000..8ba6e70730 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_butt.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_centerline.png b/src/bonsai/bonsai/bim/data/icons/dm_centerline.png new file mode 100644 index 0000000000..60ce5f0ce2 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_centerline.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_centreline.png b/src/bonsai/bonsai/bim/data/icons/dm_centreline.png new file mode 100644 index 0000000000..60ce5f0ce2 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_centreline.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_circle.png b/src/bonsai/bonsai/bim/data/icons/dm_circle.png new file mode 100644 index 0000000000..8c5444dde1 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_circle.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_connect_mep_elements.png b/src/bonsai/bonsai/bim/data/icons/dm_connect_mep_elements.png new file mode 100644 index 0000000000..8239fd14a6 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_connect_mep_elements.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_decomposition.png b/src/bonsai/bonsai/bim/data/icons/dm_decomposition.png new file mode 100644 index 0000000000..68fbb5ff6c Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_decomposition.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_draw.png b/src/bonsai/bonsai/bim/data/icons/dm_draw.png new file mode 100644 index 0000000000..84d60b656b Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_draw.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_edit_axis.png b/src/bonsai/bonsai/bim/data/icons/dm_edit_axis.png new file mode 100644 index 0000000000..e8700e5dd7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_edit_axis.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_edit_profile.png b/src/bonsai/bonsai/bim/data/icons/dm_edit_profile.png new file mode 100644 index 0000000000..81e28320b7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_edit_profile.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_edit_railing_path.png b/src/bonsai/bonsai/bim/data/icons/dm_edit_railing_path.png new file mode 100644 index 0000000000..8c909b4256 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_edit_railing_path.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_edit_roof_path.png b/src/bonsai/bonsai/bim/data/icons/dm_edit_roof_path.png new file mode 100644 index 0000000000..a9a6ee7fc0 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_edit_roof_path.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_extend.png b/src/bonsai/bonsai/bim/data/icons/dm_extend.png new file mode 100644 index 0000000000..7374945a28 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_extend.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_exterior.png b/src/bonsai/bonsai/bim/data/icons/dm_exterior.png new file mode 100644 index 0000000000..c436505b36 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_exterior.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_fillet.png b/src/bonsai/bonsai/bim/data/icons/dm_fillet.png new file mode 100644 index 0000000000..234752ae9d Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_fillet.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_flip.png b/src/bonsai/bonsai/bim/data/icons/dm_flip.png new file mode 100644 index 0000000000..55cc601555 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_flip.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_interior.png b/src/bonsai/bonsai/bim/data/icons/dm_interior.png new file mode 100644 index 0000000000..1b7b9903d2 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_interior.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_join.png b/src/bonsai/bonsai/bim/data/icons/dm_join.png new file mode 100644 index 0000000000..235f85935d Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_join.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_merge.png b/src/bonsai/bonsai/bim/data/icons/dm_merge.png new file mode 100644 index 0000000000..e9ec7312a7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_merge.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_mirror.png b/src/bonsai/bonsai/bim/data/icons/dm_mirror.png new file mode 100644 index 0000000000..826c392b85 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_mirror.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_mitre.png b/src/bonsai/bonsai/bim/data/icons/dm_mitre.png new file mode 100644 index 0000000000..3b220db2f6 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_mitre.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_offset.png b/src/bonsai/bonsai/bim/data/icons/dm_offset.png new file mode 100644 index 0000000000..5b4ffb4d73 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_offset.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_perform_quantity_take-off.png b/src/bonsai/bonsai/bim/data/icons/dm_perform_quantity_take-off.png new file mode 100644 index 0000000000..7ac80919d9 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_perform_quantity_take-off.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_rectangle.png b/src/bonsai/bonsai/bim/data/icons/dm_rectangle.png new file mode 100644 index 0000000000..f4323dfea6 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_rectangle.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_refresh.png b/src/bonsai/bonsai/bim/data/icons/dm_refresh.png new file mode 100644 index 0000000000..44cedbb086 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_refresh.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_regen.png b/src/bonsai/bonsai/bim/data/icons/dm_regen.png new file mode 100644 index 0000000000..79276cb2a7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_regen.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_rotate_90.png b/src/bonsai/bonsai/bim/data/icons/dm_rotate_90.png new file mode 100644 index 0000000000..122fa80764 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_rotate_90.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_set_gable_roof_angle.png b/src/bonsai/bonsai/bim/data/icons/dm_set_gable_roof_angle.png new file mode 100644 index 0000000000..d3dbf2f3c6 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_set_gable_roof_angle.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_split.png b/src/bonsai/bonsai/bim/data/icons/dm_split.png new file mode 100644 index 0000000000..03018a8681 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_split.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_toggle_openings.png b/src/bonsai/bonsai/bim/data/icons/dm_toggle_openings.png new file mode 100644 index 0000000000..145bcfa38e Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_toggle_openings.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_unassign.png b/src/bonsai/bonsai/bim/data/icons/dm_unassign.png new file mode 100644 index 0000000000..f2148e4949 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_unassign.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/dm_unjoin_walls.png b/src/bonsai/bonsai/bim/data/icons/dm_unjoin_walls.png new file mode 100644 index 0000000000..453415aaa3 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/dm_unjoin_walls.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/edit_axis.png b/src/bonsai/bonsai/bim/data/icons/edit_axis.png deleted file mode 100644 index c3a8cfe528..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/edit_axis.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/edit_profile.png b/src/bonsai/bonsai/bim/data/icons/edit_profile.png deleted file mode 100644 index 9c78530791..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/edit_profile.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/extend.png b/src/bonsai/bonsai/bim/data/icons/extend.png deleted file mode 100644 index c0500a9ed9..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/extend.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/exterior.png b/src/bonsai/bonsai/bim/data/icons/exterior.png deleted file mode 100644 index dcec153621..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/exterior.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/flip.png b/src/bonsai/bonsai/bim/data/icons/flip.png deleted file mode 100644 index a59a7e46ba..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/flip.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/interior.png b/src/bonsai/bonsai/bim/data/icons/interior.png deleted file mode 100644 index 2dc043eeae..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/interior.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_2-point_arc.png b/src/bonsai/bonsai/bim/data/icons/lm_2-point_arc.png new file mode 100644 index 0000000000..d9057806a7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_2-point_arc.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_3-point_arc.png b/src/bonsai/bonsai/bim/data/icons/lm_3-point_arc.png new file mode 100644 index 0000000000..970710b2d8 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_3-point_arc.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_add.png b/src/bonsai/bonsai/bim/data/icons/lm_add.png new file mode 100644 index 0000000000..0ecbed228f Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_add.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_add_bend.png b/src/bonsai/bonsai/bim/data/icons/lm_add_bend.png new file mode 100644 index 0000000000..daf84e0739 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_add_bend.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_add_transition.png b/src/bonsai/bonsai/bim/data/icons/lm_add_transition.png new file mode 100644 index 0000000000..12b8b1fdaf Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_add_transition.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_add_type.png b/src/bonsai/bonsai/bim/data/icons/lm_add_type.png new file mode 100644 index 0000000000..4fb18f4f39 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_add_type.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_add_void.png b/src/bonsai/bonsai/bim/data/icons/lm_add_void.png new file mode 100644 index 0000000000..58d5d4483a Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_add_void.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_apply_axis.png b/src/bonsai/bonsai/bim/data/icons/lm_apply_axis.png new file mode 100644 index 0000000000..8f9725463b Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_apply_axis.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_apply_profile.png b/src/bonsai/bonsai/bim/data/icons/lm_apply_profile.png new file mode 100644 index 0000000000..25d94dd6f3 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_apply_profile.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_apply_void.png b/src/bonsai/bonsai/bim/data/icons/lm_apply_void.png new file mode 100644 index 0000000000..a31068186e Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_apply_void.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_assign.png b/src/bonsai/bonsai/bim/data/icons/lm_assign.png new file mode 100644 index 0000000000..13593cc58b Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_assign.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_butt.png b/src/bonsai/bonsai/bim/data/icons/lm_butt.png new file mode 100644 index 0000000000..baae236e38 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_butt.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_centerline.png b/src/bonsai/bonsai/bim/data/icons/lm_centerline.png new file mode 100644 index 0000000000..668c821754 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_centerline.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_centreline.png b/src/bonsai/bonsai/bim/data/icons/lm_centreline.png new file mode 100644 index 0000000000..668c821754 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_centreline.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_circle.png b/src/bonsai/bonsai/bim/data/icons/lm_circle.png new file mode 100644 index 0000000000..02e5dfdd2a Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_circle.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_connect_mep_elements.png b/src/bonsai/bonsai/bim/data/icons/lm_connect_mep_elements.png new file mode 100644 index 0000000000..25e314d574 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_connect_mep_elements.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_decomposition.png b/src/bonsai/bonsai/bim/data/icons/lm_decomposition.png new file mode 100644 index 0000000000..a54ae7ae05 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_decomposition.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_draw.png b/src/bonsai/bonsai/bim/data/icons/lm_draw.png new file mode 100644 index 0000000000..4b8a6717c7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_draw.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_edit_axis.png b/src/bonsai/bonsai/bim/data/icons/lm_edit_axis.png new file mode 100644 index 0000000000..181582bbd9 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_edit_axis.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_edit_profile.png b/src/bonsai/bonsai/bim/data/icons/lm_edit_profile.png new file mode 100644 index 0000000000..5ca6dbe99b Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_edit_profile.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_edit_railing_path.png b/src/bonsai/bonsai/bim/data/icons/lm_edit_railing_path.png new file mode 100644 index 0000000000..a726419632 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_edit_railing_path.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_edit_roof_path.png b/src/bonsai/bonsai/bim/data/icons/lm_edit_roof_path.png new file mode 100644 index 0000000000..686a4e53ca Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_edit_roof_path.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_extend.png b/src/bonsai/bonsai/bim/data/icons/lm_extend.png new file mode 100644 index 0000000000..afefb0e6b9 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_extend.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_exterior.png b/src/bonsai/bonsai/bim/data/icons/lm_exterior.png new file mode 100644 index 0000000000..b10fdd3ef0 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_exterior.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_fillet.png b/src/bonsai/bonsai/bim/data/icons/lm_fillet.png new file mode 100644 index 0000000000..c29862e216 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_fillet.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_flip.png b/src/bonsai/bonsai/bim/data/icons/lm_flip.png new file mode 100644 index 0000000000..b9e45b3e60 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_flip.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_ifc.png b/src/bonsai/bonsai/bim/data/icons/lm_ifc.png new file mode 100644 index 0000000000..d4b0f9ce0c Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_ifc.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_interior.png b/src/bonsai/bonsai/bim/data/icons/lm_interior.png new file mode 100644 index 0000000000..db119ca332 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_interior.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_join.png b/src/bonsai/bonsai/bim/data/icons/lm_join.png new file mode 100644 index 0000000000..ff116439df Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_join.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_merge.png b/src/bonsai/bonsai/bim/data/icons/lm_merge.png new file mode 100644 index 0000000000..271766bc1f Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_merge.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_mirror.png b/src/bonsai/bonsai/bim/data/icons/lm_mirror.png new file mode 100644 index 0000000000..c04bbc38c7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_mirror.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_mitre.png b/src/bonsai/bonsai/bim/data/icons/lm_mitre.png new file mode 100644 index 0000000000..abaffeaab7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_mitre.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_offset.png b/src/bonsai/bonsai/bim/data/icons/lm_offset.png new file mode 100644 index 0000000000..36df4a2349 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_offset.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_perform_quantity_take-off.png b/src/bonsai/bonsai/bim/data/icons/lm_perform_quantity_take-off.png new file mode 100644 index 0000000000..8dd986cc9c Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_perform_quantity_take-off.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_rectangle.png b/src/bonsai/bonsai/bim/data/icons/lm_rectangle.png new file mode 100644 index 0000000000..c87d1abba7 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_rectangle.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_refresh.png b/src/bonsai/bonsai/bim/data/icons/lm_refresh.png new file mode 100644 index 0000000000..d19a113b61 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_refresh.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_regen.png b/src/bonsai/bonsai/bim/data/icons/lm_regen.png new file mode 100644 index 0000000000..3605ddc43e Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_regen.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_rotate_90.png b/src/bonsai/bonsai/bim/data/icons/lm_rotate_90.png new file mode 100644 index 0000000000..eaf26b2b5f Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_rotate_90.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_set_gable_roof_angle.png b/src/bonsai/bonsai/bim/data/icons/lm_set_gable_roof_angle.png new file mode 100644 index 0000000000..0411b642f3 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_set_gable_roof_angle.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_split.png b/src/bonsai/bonsai/bim/data/icons/lm_split.png new file mode 100644 index 0000000000..0fb0f5110b Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_split.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_toggle_openings.png b/src/bonsai/bonsai/bim/data/icons/lm_toggle_openings.png new file mode 100644 index 0000000000..68016af132 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_toggle_openings.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_unassign.png b/src/bonsai/bonsai/bim/data/icons/lm_unassign.png new file mode 100644 index 0000000000..953ffb9799 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_unassign.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/lm_unjoin_walls.png b/src/bonsai/bonsai/bim/data/icons/lm_unjoin_walls.png new file mode 100644 index 0000000000..d6173d4898 Binary files /dev/null and b/src/bonsai/bonsai/bim/data/icons/lm_unjoin_walls.png differ diff --git a/src/bonsai/bonsai/bim/data/icons/merge.png b/src/bonsai/bonsai/bim/data/icons/merge.png deleted file mode 100644 index 93adf4dc7d..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/merge.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/mirror.png b/src/bonsai/bonsai/bim/data/icons/mirror.png deleted file mode 100644 index 229802d7a5..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/mirror.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/mitre.png b/src/bonsai/bonsai/bim/data/icons/mitre.png deleted file mode 100644 index 3b1ecbfbf5..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/mitre.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/perform_quantity_take-off.png b/src/bonsai/bonsai/bim/data/icons/perform_quantity_take-off.png deleted file mode 100644 index 1f0e258bb2..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/perform_quantity_take-off.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/regen.png b/src/bonsai/bonsai/bim/data/icons/regen.png deleted file mode 100644 index 6b29ea3e51..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/regen.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/rotate_90.png b/src/bonsai/bonsai/bim/data/icons/rotate_90.png deleted file mode 100644 index 5733b85f83..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/rotate_90.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/split.png b/src/bonsai/bonsai/bim/data/icons/split.png deleted file mode 100644 index 45d789e45c..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/split.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/unassign.png b/src/bonsai/bonsai/bim/data/icons/unassign.png deleted file mode 100644 index 0359ca3824..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/unassign.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/unjoin_walls.png b/src/bonsai/bonsai/bim/data/icons/unjoin_walls.png deleted file mode 100644 index 16f3272c8f..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/unjoin_walls.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/icons/void.png b/src/bonsai/bonsai/bim/data/icons/void.png deleted file mode 100644 index 366fa9eb9d..0000000000 Binary files a/src/bonsai/bonsai/bim/data/icons/void.png and /dev/null differ diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index c663e7d091..55a1b73f29 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -1,6 +1,6 @@ import sys import os -import webbrowser + bonsai_lib_path = os.environ.get("BONSAI_LIB_PATH") bonsai_version = os.environ.get("BONSAI_VERSION") @@ -13,12 +13,15 @@ from aiohttp import web import socketio import pystache import json +import base64 +import xml.etree.ElementTree as ET sio_port = 8080 # default port sio = socketio.AsyncServer( cors_allowed_origins="*", async_mode="aiohttp", + max_http_buffer_size=10000000 ) app = web.Application() sio.attach(app) @@ -38,9 +41,9 @@ class WebNamespace(socketio.AsyncNamespace): async def on_connect(self, sid, environ): print(f"Web client connected: {sid}") + await sio.emit("theme_data", blender_theme, namespace="/web", room=sid) if blender_messages: await sio.emit("connected_clients", list(blender_messages.keys()), namespace="/web", room=sid) - await sio.emit("theme_data", blender_theme, namespace="/web", room=sid) await self.send_cached_messages(sid) async def on_disconnect(self, sid): @@ -55,11 +58,9 @@ class WebNamespace(socketio.AsyncNamespace): ) async def on_get_svg(self, sid, data): - print("hello world!") file_path = data["path"] - with open(file_path, "r") as file: - svg_data = file.read() - await sio.emit("svg_data", svg_data, room=sid, namespace="/web") + svg = await self.process_svg(file_path) + await sio.emit("svg_data", svg, room=sid, namespace="/web") async def send_cached_messages(self, sid): # Send cached messages to the connected web client @@ -70,6 +71,38 @@ class WebNamespace(socketio.AsyncNamespace): await self.emit("gantt_data", {"blenderId": blenderId, "data": messages["gantt_data"]}, room=sid) if "demo_data" in messages: await self.emit("demo_data", {"blenderId": blenderId, "data": messages["demo_data"]}, room=sid) + if "cost_items" in messages: + await self.emit("cost_items", {"blenderId": blenderId, "data": messages["cost_items"]}, room=sid) + + async def process_svg(self, file_path): + def encode_image(filepath): + # Encode file content to base64 string + with open(filepath, "rb") as file: + encoded_string = base64.b64encode(file.read()).decode("utf-8") + return f"data:image;base64,{encoded_string}" + + file_dir = os.path.dirname(file_path) + + ET.register_namespace("", "http://www.w3.org/2000/svg") + ET.register_namespace("xlink", "http://www.w3.org/1999/xlink") + + tree = ET.parse(file_path) + root = tree.getroot() + + namespaces = { + "svg": "http://www.w3.org/2000/svg", + "xlink": "http://www.w3.org/1999/xlink", + } + + for element in root.findall(".//svg:image[@xlink:href]", namespaces): + href = element.get("{http://www.w3.org/1999/xlink}href") + if href and href.endswith((".png", ".svg", ".jpeg")): + img_path = os.path.join(file_dir, href) + if os.path.exists(img_path): + base64_data = encode_image(img_path) + element.set("{http://www.w3.org/1999/xlink}href", base64_data) + return ET.tostring(root, "unicode") + # Blender namespace class BlenderNamespace(socketio.AsyncNamespace): @@ -115,13 +148,57 @@ class BlenderNamespace(socketio.AsyncNamespace): blender_theme = data await sio.emit("theme_data", data, namespace="/web") - - # this function will be called when the event demo_data is emitted + # this function will be called when the event demo_data is emitted async def on_demo_data(self, sid, data): print(f"Demo data from Blender client {sid}") blender_messages[sid]["demo_data"] = data await sio.emit("demo_data", {"blenderId": sid, "data": data}, namespace="/web") + async def on_work_schedule_info(self, sid, data): + print(f"Work schedule info from Blender client {sid}") + blender_messages[sid]["work_schedule_info"] = data + await sio.emit("work_schedule_info", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_items(self, sid, data): + print(f"Cost items data from Blender client {sid}") + blender_messages[sid]["cost_items"] = data + await sio.emit("cost_items", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_schedules(self, sid, data): + print(f"Cost schedule info from Blender client {sid}") + blender_messages[sid]["cost_schedules"] = data + await sio.emit("cost_schedules", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_values(self, sid, data): + print(f"Cost values from Blender client {sid}") + blender_messages[sid]["cost_values"] = data + await sio.emit("cost_values", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_value(self, sid, data): + print(f"Cost values from Blender client {sid}") + blender_messages[sid]["cost_value"] = data + await sio.emit("cost_value", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_predefined_types(self, sid, data): + print(f"Predefined types from Blender client {sid}") + blender_messages[sid]["predefined_types"] = data + await sio.emit("predefined_types", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_quantities(self, sid, data): + print(f"Selected products from Blender client {sid}") + blender_messages[sid]["quantities"] = data + await sio.emit("quantities", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_classification(self, sid, data): + print(f"Classification from Blender client {sid}") + blender_messages[sid]["classification"] = data + await sio.emit("classification", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_message(self, sid, data): + print(f"Error from Blender client {sid}") + blender_messages[sid]["error"] = data + await sio.emit("message", {"blenderId": sid, "data": data}, namespace="/web") + async def schedules(request): with open("templates/index.html", "r") as f: @@ -130,6 +207,13 @@ async def schedules(request): return web.Response(text=html_content, content_type="text/html") +async def costing(request): + with open("templates/costing.html", "r") as f: + template = f.read() + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + return web.Response(text=html_content, content_type="text/html") + + async def sequencing(request): with open("templates/gantt.html", "r") as f: template = f.read() @@ -179,6 +263,7 @@ sio.register_namespace(BlenderNamespace("/blender")) app.router.add_get("/", schedules) app.router.add_get("/documentation", documentation) app.router.add_get("/sequencing", sequencing) +app.router.add_get("/costing", costing) app.router.add_get("/demo", demo) # Add static files diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css new file mode 100644 index 0000000000..4d74fbc65f --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css @@ -0,0 +1,27 @@ +.card-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.card { + background-color: var(--blender-even-row, var(--nav-bg-color)); + display: flex; + flex-direction: column; + overflow-y: auto; + flex: 1; +} + +.card-header { + background-color: var(--blender-panel-header, var(--bg-color)); + border: 1px solid var(--blender-tab-outline, var(--border-color)); + flex-shrink: 0; +} + +.card-body { + background-color: var(--blender-panel-background, var(--bg-color)); + border: 1px solid var(--blender-tab-outline, var(--border-color)); + overflow-y: auto; + flex: 1; +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css new file mode 100644 index 0000000000..4cc83a77a2 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css @@ -0,0 +1,35 @@ + +th { + position: relative; +} + +.context-menu { + display: none; + position: absolute; + background-color: rgb(51, 51, 51); + border: 1px solid #ccc; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + color: white; +} +.context-menu button { + display: block; + width: 100%; + padding: 8px; + border: none; + background: none; + text-align: left; + cursor: pointer; +} +.context-menu button:hover { + background-color: #f0f0f0; + color: black; +} + +/* Context menu button is clicked, should zoom into object and change color */ +.context-menu button:active { + background-color: #ff9634; + color: rgba(37, 51, 77, 0.466); + /* zoom */ + transform: scale(0.9); +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/floatingForm.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/floatingForm.css new file mode 100644 index 0000000000..b546803bf1 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/floatingForm.css @@ -0,0 +1,71 @@ +.floating-form { + width: 50vw; + max-height: 80vh; + position: absolute; + background-color: var(--background-color); + border: 1px solid black; + z-index: 9999; + cursor: move; + user-select: none; + padding-bottom: var(--padding-medium); +} + +[id^="enable-editing-quantities"].floating-form { + height: 70vh; +} + +.form-header { + display: flex; + justify-content: center; + align-items: center; + background-color: #323936; + padding: var(--padding-medium); + font-size: large; + border-bottom: 1px solid #ddd; + border-radius: 6px 6px 0 0; + height: 5%; + position: relative; +} + +.form-container { + height: 95%; + padding-left: var(--padding-medium); + padding-right: var(--padding-medium); +} + +.form-section { + margin-bottom: 1em; + height: 70%; +} + +.form-section h3 { + margin-bottom: 0.5em; +} + +.summary-section { + margin-top: 1em; +} + +.summary-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + text-align: left; +} + +.floating-form tr { + margin-bottom: 10px; +} + +.floating-form td { + padding: 3px; +} + +.table-container { + display: flex; + flex-direction: column; + border: 1px solid #ccc; + border-radius: 5px; + overflow: auto; + max-height: 400px; +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/headerBar.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/headerBar.css new file mode 100644 index 0000000000..4c431f407b --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/headerBar.css @@ -0,0 +1,49 @@ +.ribbon-bar { + display: flex; + gap: 10px; + margin-bottom: 20px; + border: 1px solid #444; + width: 100%; + justify-content: center; +} + +.ribbon-bar .action-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + background-color: #444; + color: #fff; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.3s; + border-left: 1px solid #666; +} + +.ribbon-bar .action-button:hover { + background-color: #666; +} + +.ribbon-bar .action-button::after { + content: attr(data-tooltip); + position: absolute; + bottom: -30px; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 5px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + font-size: 12px; +} + +.ribbon-bar .action-button:hover::after { + opacity: 1; +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/layeredViewPanel.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/layeredViewPanel.css new file mode 100644 index 0000000000..b564940b5f --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/layeredViewPanel.css @@ -0,0 +1,43 @@ + +.selected { + border-color: #28a745; + outline: none; + box-shadow: 0 0 0 3px #28a745; + color: #ddd; + } + + + .classificationView { + border-left: 5px solid #28a745; + min-height: 200px; + overflow-y: auto; + overflow-x: hidden; + padding: 5px; + max-height: 400px; + } + + .selectedClassificationText { + color: #28a745; + } + + .back-button { + margin-bottom: 20px; + } + + .back-button:disabled { + cursor: not-allowed; + background-color: #ccc; + } + + .classification-cell { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + } + + .classification { + height: 100%; + } + \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/message.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/message.css new file mode 100644 index 0000000000..2c34fcd34f --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/message.css @@ -0,0 +1,67 @@ + +.message { + position: relative; + padding: 10px; + border-radius: 5px; + margin: 10px 0; + display: flex; + align-items: center; + justify-content: flex-start; + } + + .message span { + width: 80%; + } + + .message .icon { + margin-right: 10px; + } + + .message .close-button { + width: 24px; + height: 24px; + display: flex; + align-items: center; + transition: background-color 0.3s, color 0.3s; + padding-left: 5px; + } + + .message .close-button:hover { + background-color: #ffcccc; + } + + .error { + color: red; + border: 1px solid red; + background-color: #ffe6e6; + } + + .error .close-button { + color: red; + } + + .error .close-button:hover { + color: darkred; + } + + .success { + color: green; + border: 1px solid green; + background-color: #e6ffe6; + } + + .success .close-button { + color: green; + } + + .success .close-button:hover { + background-color: #ccffcc; + color: darkgreen; + } + + .card { + margin: 10px; + } + .card-body { + height: 10vh; + } \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/table.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/table.css new file mode 100644 index 0000000000..32e17746ae --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/table.css @@ -0,0 +1,117 @@ +.cost-tables { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(700px, 2fr)); + grid-auto-rows: minmax(350px, auto); + gap: 10px; + font-size: var(--fontSize); +} + +.schedule { + border: 1px solid #ccc; + position: relative; + overflow: hidden; /* Ensure content doesn't overflow */ + display: flex; + flex-direction: column; +} + +.table-wrapper { + flex: 1; + overflow-y: auto; /* Enables vertical scrolling */ +} + +thead .resizer { + position: absolute; + right: 0; + top: 0; + width: 5px; + height: 100%; + cursor: col-resize; + user-select: none; +} + +table { + table-layout: auto; + width: 100%; + border-collapse: collapse; + border: 1px solid #ccc; /* Optional: adds a border around the table */ +} + +thead { + background-color: #727272; + position: sticky; + top: 0; + z-index: 1; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +tbody { + width: 100%; /* Ensure full width for alignment */ +} + +th, +td { + padding: 5px; + border-right: 1px solid #ccc; + text-align: left; +} + +th, +td { + word-wrap: break-word; + border-right: 1px solid #ddd; + text-align: center; + height: 100%; + width: auto; + overflow: hidden; +} + +th { + border-bottom: 1px solid #ddd; +} + +td { + align-items: center; +} + +.actions-column { + transition: opacity 0.3s ease-in-out; + opacity: 0; + width: fit-content; + min-width: fit-content; + max-width: 50%; +} + +[id^="cost-items"] tr:hover .actions-column { + opacity: 1; +} + +.selected-cell, +.clickable-cell:hover { + border: 2px solid var(--highlight-over); + cursor: pointer; +} + +td { + position: relative; +} + +@media (max-width: 1024px) { + .cost-tables { + grid-template-columns: 2fr; /* One column on smaller screens */ + } +} + +@media (max-width: 768px) { + .cost-tables { + grid-template-columns: 1fr; /* One column on smaller screens */ + } +} + +.table-wrapper.collapsed { + max-height: 40px; + overflow: hidden; +} + +.table-wrapper .form-header { + cursor: pointer; +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css new file mode 100644 index 0000000000..a64a3690d4 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css @@ -0,0 +1,289 @@ +@import url("./shared.css"); +@import url("./components/card.css"); +@import url("./components/contextMenu.css"); +@import url("./components/headerBar.css"); +@import url("./components/table.css"); +@import url("./components/floatingForm.css"); +@import url("./components/message.css"); +@import url("./components/layeredViewPanel.css"); + +:root { + --fontSize: 14px; + --background-color: #363636; + --button-background: #28a745; + --button-hover-background: #ed5700; + --highlight-over: #ed5700; + --scrollbar-track-color: #f1f1f1; + --scrollbar-thumb-color: #888; + --scrollbar-thumb-hover-color: #555; + --padding-small: 5px; + --padding-medium: 10px; +} + +body { + margin: 0; + padding: 0; + box-sizing: border-box; + height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.floating-form .table-container { + max-height: 80%; + box-shadow: 0 5px 5px 0 #254e3c; +} + +#cost-items { + max-height: calc(100vh - var(--margin-small)); +} + +[id^="cost-values-form"] table { + max-height: 50%; + table-layout: fixed; +} + +[id^="selected-products"] .form-container { + height: 50vh; +} + +.action-button { + position: relative; + background-color: var(--button-background); + border: none; + color: #fff; + padding: 0.5em 1em; + cursor: pointer; + border-radius: 6px; + font-weight: bold; + text-decoration: none; + display: inline-block; + transition: background-color 0.3s ease; + width: fit-content; + margin: 0.25em; +} + +.action-button::after { + content: attr(data-tooltip); + position: absolute; + bottom: -30px; + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 5px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + font-size: 12px; +} + +.action-button:hover::after { + opacity: 1; + border-color: var(--highlight-over); + border-width: 2px; +} + +.active-btn { + background-color: var(--highlight-over); +} + +.action-button:hover { + background-color: var(--button-hover-background); +} + +.close-button { + position: absolute; + top: 10px; + right: 10px; + font-size: 20px; + cursor: pointer; +} + +.settings-menu { + display: none; + position: absolute; + background-color: var(--background-color); + border: 1px solid black; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + z-index: 9999; + cursor: move; + padding: 20px; + user-select: none; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-track-color); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover-color); +} + +.flex-container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.nested { + display: none; +} + +.expand-collapse { + cursor: pointer; +} + +.caret-cell { + border: none; + background: none; + padding: 0; +} + +input, +select { + background-color: var(--blender-button-background, var(--bg-color)); + color: var(--blender-button-text, var(--text-color)); + border-color: var(--blender-button-border, var(--border-color)); + transition: filter 0.2s ease; + transition: border-color 0.2s cubic-bezier(0.3, 0, 0.5, 1); +} + +input:focus { + border-color: #28a745; + outline: none; + box-shadow: 0 0 0 3px #28a745; + color: #ddd; +} + +#cost-items input { + border-radius: 6px; + padding: 6px 8px; + font-size: 14px; + line-height: 20px; + font-size: var(--fontSize); + min-width: 50px; +} + +form input, +form select { + border-radius: 6px; + padding: 3px 4px; + font-size: 12px; + line-height: 10px; + font-size: inherit; + height: 100%; + max-height: 30px; + max-width: 100%; + box-sizing: border-box; +} + +#cost-items tr:hover, +.highlighted { + background-color: #28a74657; +} + +.highlighted { + background-color: #28a74657; +} + +.cost-values-table { + width: 100%; + border-collapse: collapse; +} + +.cost-values-table th, +.cost-values-table td { + border: 1px solid #ddd; + padding: 8px; +} + +.cost-values-table th { + background-color: #444444; + text-align: center; +} + +form table { + max-height: 50%; +} + +.subtotal-row { + background-color: var(--button-hover-background); + font-weight: bold; +} + +.append-right { + right: 0; + top: 50%; + position: absolute; + transform: translateY(-50%); +} + +i { + margin-right: 5px; +} + + +#column-selector { + display: flex; + flex-direction: column; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + max-width: 100%; + font-family: Arial, sans-serif; + height: 10vh; + overflow-y: auto; +} + +#column-selector label { + display: flex; + align-items: center; + margin-bottom: 10px; + cursor: pointer; + padding: 5px; + border-radius: 4px; + transition: background-color 0.3s ease; +} + +#column-selector input[type="checkbox"] { + margin-right: 10px; +} + +#column-selector label:hover { + background-color: var(--highlight-over); +} + +#column-selector label:last-child { + margin-bottom: 0; +} + +.switch-bar { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 10px 0; +} + +.switch-bar .action-button { + border: 1px solid #000000; + border-radius: 5px 5px 0 0; + margin: 0; + margin-left: -5px; +} + + + + + + diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/demo.css b/src/bonsai/bonsai/bim/data/webui/static/css/demo.css index 9a2460c90f..975bb318d1 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/demo.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/demo.css @@ -54,10 +54,7 @@ body { #container { flex: 1; - margin-top: var(--margin-medium); - margin-left: var(--margin-small); - margin-right: var(--margin-small); - margin-bottom: var(--margin-medium); + padding: var(--margin-small); } h3 { diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css b/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css index c2779d7e9e..aef677f683 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css @@ -1,3 +1,5 @@ +@import url("./components/card.css"); + :root { --font-family: Arial, sans-serif; --base-font-size: 16px; @@ -55,10 +57,7 @@ body { } #container { - margin-top: var(--margin-medium); - margin-left: var(--margin-tiny); - margin-right: var(--margin-tiny); - margin-bottom: var(--margin-medium); + padding: var(--margin-small); height: calc(100vh - var(--nav-height)); box-sizing: border-box; display: flex; @@ -253,33 +252,7 @@ button:hover { overflow: hidden; } -.card-container { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} -.card { - background-color: var(--blender-even-row, var(--nav-bg-color)); - display: flex; - flex-direction: column; - overflow-y: auto; - flex: 1; -} - -.card-header { - background-color: var(--blender-panel-header, var(--bg-color)); - border: 1px solid var(--blender-tab-outline, var(--border-color)); - flex-shrink: 0; -} - -.card-body { - background-color: var(--blender-panel-background, var(--bg-color)); - border: 1px solid var(--blender-tab-outline, var(--border-color)); - overflow-y: auto; - flex: 1; -} li.svg-name { list-style-type: none; diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css b/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css index 9f97eb6b82..6c9b30e089 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css @@ -1,289 +1,166 @@ -:root { - --font-family: Arial, sans-serif; - --base-font-size: 16px; - --margin-tiny: 0.125rem; - --margin-small: 0.625rem; - --margin-medium: 1.25rem; - --margin-large: 2.5rem; - --padding-tiny: 0.125rem; - --padding-small: 0.625rem; - --padding-medium: 1rem; - --font-size-small: 1rem; - --font-size-large: 1.2rem; - --logo-height: 1.5rem; - --nav-height: 0.625rem; - --border-radius: 0.3rem; - --folder-collapse-font-size: 0.75rem; - --folder-collapse-font-family: Courier, "Courier New", monospace; - --box-shadow: 0 0 0.7rem #5f5f5f66; -} - -:root.dark { - color-scheme: dark; - --bg-color: #252525; - --primary-text-color: #e0e0e0; - --secondary-text-color: #c7c7c7; - --nav-bg-color: #121212; - --nav-border-color: #25682a; - --nav-link-color: #fff; - --nav-link-hover-color: #3fb449; - --warning-color: #FFDB8F; - --border-color: #464444; - --hover-bg-color: #3a3a3a; - --highlight-color: #009136; - --task-complete-bg-color: #777777; - --milestone-border-color: #ffffff; - --group-item-bg-color: #4b4b4b; - --input-bg-color: #3b3b3b; - --input-border-color: #000; - --border-color: #464444; -} - -:root.light { - color-scheme: light; - --bg-color: #ffffff; - --primary-text-color: #000000; - --nav-bg-color: #f8f8f8; - --nav-border-color: #cccccc; - --nav-link-color: #000000; - --nav-link-hover-color: #38a63d; - --warning-color: #FF4500; - --border-color: #222; -} - - -*:not(td, tr, td, .gmain) { - border-radius: var(--border-radius) !important; -} - -html { - font-size: var(--base-font-size); -} - -body { - background-color: var(--blender-window-background, var(--bg-color)); - color: var(--blender-text, var(--primary-text-color)); - margin: 0; - font-family: var(--font-family); - display: flex; - flex-direction: column; - min-height: 100vh; -} - -#container { - flex: 1; - margin-top: var(--margin-medium); - margin-left: var(--margin-small); - margin-right: var(--margin-small); - margin-bottom: var(--margin-medium); -} - -h3 { - margin-top: 0; -} - -nav { - background-color: var(--blender-top-bar-header, var(--nav-bg-color)); - height: var(--nav-height); - padding: var(--padding-medium) 0; - display: flex; - align-items: center; - position: relative; - border-bottom: 2px solid var(--blender-tab-outline, var(--nav-border-color)); -} - -nav .logo { - margin-left: var(--margin-large); - height: var(--logo-height); -} - -nav ul { - list-style-type: none; - margin: 0; - display: flex; - flex: 1; - justify-content: center; -} - -nav ul li { - margin-right: var(--margin-large); -} - -nav ul li a { - text-decoration: none; - color: var(--blender-text, var(--nav-link-color)); -} - -nav ul li a:hover, -nav ul li a.active { - color: var(--blender-selected-object, var(--nav-link-hover-color)); -} - -.warning, -.warning-icon { - color: var(--blender-info-warning, var(--warning-color)); -} +@import url("./components/card.css"); +@import url("./shared.css"); .table-description { - margin-bottom: var(--margin-medium); - width: 50%; - background-color: transparent; - border-collapse: collapse; - border-spacing: 0; - border-color: var(--blender-tab-outline, var(--border-color)); - border: 1px solid var(--blender-tab-outline, var(--border-color)); - border-radius: 0.25rem; - box-shadow: var(--box-shadow); - text-align: center; + margin-bottom: var(--margin-medium); + width: 50%; + background-color: transparent; + border-collapse: collapse; + border-spacing: 0; + border-color: var(--blender-tab-outline, var(--border-color)); + border: 1px solid var(--blender-tab-outline, var(--border-color)); + border-radius: 0.25rem; + box-shadow: var(--box-shadow); + text-align: center; } .table-description tr { - display: table-row; - border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); + display: table-row; + border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); } .table-description tr:nth-child(even) { - background-color: var(--blender-even-row); + background-color: var(--blender-even-row); } .table-description tr:nth-child(odd) { - background-color: var(--blender-odd-row); + background-color: var(--blender-odd-row); } #toggle-theme { - border: none; - background: none; - cursor: pointer; - font-size: var(--font-size-large); - margin-right: var(--margin-medium); + border: none; + background: none; + cursor: pointer; + font-size: var(--font-size-large); + margin-right: var(--margin-medium); } #toggle-theme:focus { - outline: none; + outline: none; } #client-list { - position: absolute; - top: calc(0 + var(--nav-height)); - overflow-y: auto; - overflow: hidden; - transition: opacity 0.3s ease-out, visibility 0.3s ease-out; - border: 1px solid var(--blender-tab-outline, var(--border-color)); - background-color: var(--blender-panel-background, var(--nav-bg-color)); - z-index: 1; - opacity: 0; - visibility: hidden; - width: auto; + position: absolute; + top: calc(0 + var(--nav-height)); + overflow-y: auto; + overflow: hidden; + transition: opacity 0.3s ease-out, visibility 0.3s ease-out; + border: 1px solid var(--blender-tab-outline, var(--border-color)); + background-color: var(--blender-panel-background, var(--nav-bg-color)); + z-index: 1; + opacity: 0; + visibility: hidden; + width: auto; } #client-list.show { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } #connected-list-div { - display: inline-block; + display: inline-block; } .client { - padding: var(--margin-small); - border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); - cursor: pointer; + padding: var(--margin-small); + border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); + cursor: pointer; } .client-details { - max-height: 0; - overflow: hidden; - padding-left: var(--padding-small); - transition: max-height 0.2s ease-out, padding 0.2s ease-out, opacity 0.1s ease-out; - background-color: var(--blender-panel-background, var(--nav-bg-color)); - margin-top: var(--margin-tiny); - opacity: 0; + max-height: 0; + overflow: hidden; + padding-left: var(--padding-small); + transition: max-height 0.2s ease-out, padding 0.2s ease-out, + opacity 0.1s ease-out; + background-color: var(--blender-panel-background, var(--nav-bg-color)); + margin-top: var(--margin-tiny); + opacity: 0; } .client-details.show { - max-height: none; - opacity: 1; + max-height: none; + opacity: 1; } .client-detail { - border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); - padding: var(--padding-small); + border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); + padding: var(--padding-small); } #show-connected-button { - width: auto; - background-color: var(--blender-panel-header, var(--bg-color)); - font-size: var(--base-font-size); + width: auto; + background-color: var(--blender-panel-header, var(--bg-color)); + font-size: var(--base-font-size); } #show-connected-button:hover, .scroll-button:hover { - cursor: pointer; + cursor: pointer; } .scroll-button { - font-size: var(--base-font-size); - margin-left: var(--margin-tiny); - margin-top: var(--margin-small); + font-size: var(--base-font-size); + margin-left: var(--margin-tiny); + margin-top: var(--margin-small); } footer { - background-color: var(--blender-top-bar-header, var(--nav-bg-color)); - text-align: right; - padding: var(--padding-tiny); - border-top: 1px solid var(--blender-tab-outline, var(--nav-border-color)); + background-color: var(--blender-top-bar-header, var(--nav-bg-color)); + text-align: right; + padding: var(--padding-tiny); + border-top: 1px solid var(--blender-tab-outline, var(--nav-border-color)); } footer p { - margin: 0; - color: var(--blender-text, var(--nav-link-color)); - margin-right: var(--margin-small); - font-size: 0.8rem; + margin: 0; + color: var(--blender-text, var(--nav-link-color)); + margin-right: var(--margin-small); + font-size: 0.8rem; } .gantt-info { - margin: 0.5rem; - font-size: var(--font-size-small); - font-weight: bold; + margin: 0.5rem; + font-size: var(--font-size-small); + font-weight: bold; } .btn { - margin: 0.5rem; - font-size: var(--font-size-small); - cursor: pointer; + margin: 0.5rem; + font-size: var(--font-size-small); + cursor: pointer; } @media print { - .no-print { - display: none; - } + .no-print { + display: none; + } } - button, select { - background-color: var(--blender-button-background, var(--bg-color)); - color: var(--blender-button-text, var(--text-color)); - border-color: var(--blender-button-border, var(--border-color)); - transition: filter 0.2s ease; + background-color: var(--blender-button-background, var(--bg-color)); + color: var(--blender-button-text, var(--text-color)); + border-color: var(--blender-button-border, var(--border-color)); + transition: filter 0.2s ease; } button:hover { - filter: brightness(1.2); + filter: brightness(1.2); } /* ------------ Overwriting JSGantt CSS rules ------------ */ /* ------------------- for dark theme ------------------- */ :root.dark div.gantt { - background-color: var(--bg-color); - color: var(--secondary-text-color); + background-color: var(--bg-color); + color: var(--secondary-text-color); } :root.dark .gantt table, :root.dark .gantt td { - border-color: var(--border-color); + border-color: var(--border-color); } /* Headings and cell defaults */ @@ -296,8 +173,8 @@ button:hover { :root.dark .gname, :root.dark .ggroupitem, :root.dark .gtaskheading { - background-color: var(--bg-color); - border-color: var(--border-color); + background-color: var(--bg-color); + border-color: var(--border-color); } :root.dark .gtaskheading, @@ -313,129 +190,128 @@ button:hover { :root.dark .gchartlbl, :root.dark .gcontainercol, :root.dark .genddate { - color: var(--secondary-text-color); - border-color: var(--border-color); + color: var(--secondary-text-color); + border-color: var(--border-color); } -:root.dark .gtaskname>div { - color: var(--secondary-text-color); +:root.dark .gtaskname > div { + color: var(--secondary-text-color); } :root.dark .gtaskbarcontainer.gplan, :root.dark .gchartlbl.gcontainercol { - background: var(--bg-color); - border-color: var(--border-color); + background: var(--bg-color); + border-color: var(--border-color); } :root.dark .gtaskname div, :root.dark .gtaskheading div, :root.dark .gtaskname span { - color: var(--secondary-text-color); + color: var(--secondary-text-color); } :root.dark .gtasklist, :root.dark .gadditional { - border: var(--border-color) 1px solid; + border: var(--border-color) 1px solid; } :root.dark .gchartgrid { - background-color: var(--bg-color); + background-color: var(--bg-color); } :root.dark .glistgrid, :root.dark .glistlbl { - background-color: var(--bg-color); - border-color: var(--border-color); + background-color: var(--bg-color); + border-color: var(--border-color); } /* Dark mode scrollbar */ :root.dark .frame::-webkit-scrollbar-thumb, :root.dark .frame::-webkit-scrollbar-track { - background-color: var(--bg-color); + background-color: var(--bg-color); } /* Highlight row */ :root.dark .gitemhighlight td { - background-color: var(--highlight-color); - color: var(--nav-link-color); + background-color: var(--highlight-color); + color: var(--nav-link-color); } /* Differentiate Group, Milestone and Ordinary task items (applied to row) */ :root.dark .ggroupitem { - background-color: var(--bg-color); - font-weight: bold; - border-color: var(--border-color); + background-color: var(--bg-color); + font-weight: bold; + border-color: var(--border-color); } :root.dark .gmileitem, :root.dark .glineitem { - background-color: var(--bg-color); + background-color: var(--bg-color); } /* Task bar caption text styles */ :root.dark .gmilecaption, :root.dark .ggroupcaption, :root.dark .gcaption { - color: var(--nav-link-color); + color: var(--nav-link-color); } /* Task complete %age bar */ :root.dark .gtaskcomplete { - background-color: var(--task-complete-bg-color); + background-color: var(--task-complete-bg-color); } /* Milestones */ :root.dark .gmdtop, :root.dark .gmdbottom { - border-bottom: 5px solid var(--milestone-border-color); + border-bottom: 5px solid var(--milestone-border-color); } :root.dark .gfoldercollapse { - color: var(--secondary-text-color); - cursor: pointer; - font-weight: bold; - font-size: var(--folder-collapse-font-size); - font-family: var(--folder-collapse-font-family); + color: var(--secondary-text-color); + cursor: pointer; + font-weight: bold; + font-size: var(--folder-collapse-font-size); + font-family: var(--folder-collapse-font-family); } /* Highlight for collapsible row */ :root.dark .gname.ggroupitem { - background-color: var(--group-item-bg-color); + background-color: var(--group-item-bg-color); } /* Form label and selected highlighting */ :root.dark .gformlabel { - background-color: var(--formlabel-bg-color); - color: var(--secondary-text-color); - border: var(--formlabel-border-color) 1px solid; + background-color: var(--formlabel-bg-color); + color: var(--secondary-text-color); + border: var(--formlabel-border-color) 1px solid; } :root.dark span.gformlabel:hover { - background-color: var(--hover-bg-color); - border-color: var(--border-color); + background-color: var(--hover-bg-color); + border-color: var(--border-color); } :root.dark span.gselected { - background-color: var(--formlabel-selected-bg-color); - border-color: var(--task-complete-bg-color); - color: var(--nav-link-color); + background-color: var(--formlabel-selected-bg-color); + border-color: var(--task-complete-bg-color); + color: var(--nav-link-color); } :root.dark .gantt-inputtable { - background-color: var(--input-bg-color); - box-sizing: border-box; - border: 1px solid var(--input-border-color); - color: var(--secondary-text-color); + background-color: var(--input-bg-color); + box-sizing: border-box; + border: 1px solid var(--input-border-color); + color: var(--secondary-text-color); } /* ------------------- for blender generated theme ------------------- */ - :root.blender div.gantt, :root.blender .gantt table, :root.blender .gantt td { - color: var(--blender-text); - border-color: var(--blender-tab-outline); + color: var(--blender-text); + border-color: var(--blender-tab-outline); } :root.blender .gchartgrid, @@ -444,11 +320,10 @@ button:hover { :root.blender .gchartlbl.gcontainercol, :root.blender .gminorheading, :root.blender .gtaskcell { - background-color: var(--blender-odd-row); - border-color: var(--blender-tab-outline); + background-color: var(--blender-odd-row); + border-color: var(--blender-tab-outline); } - /* Headings and cell defaults */ :root.blender .gtaskheading, :root.blender .gchartlbl.gcontainercol .gmajorheading, @@ -461,13 +336,13 @@ button:hover { :root.blender .gchartlbl.gcontainercol .ggroupitem, :root.blender .glistlbl.gcontainercol .gtaskname, :root.blender .glistlbl.gcontainercol .gtasklist { - background-color: var(--blender-panel-header); - border-color: var(--blender-tab-outline); + background-color: var(--blender-panel-header); + border-color: var(--blender-tab-outline); } :root.blender .gtaskcellcurrent { - background-color: var(--blender-odd-row); - border-color: var(--blender-tab-outline); + background-color: var(--blender-odd-row); + border-color: var(--blender-tab-outline); } :root.blender .gtaskheading, @@ -483,103 +358,102 @@ button:hover { :root.blender .gchartlbl, :root.blender .gcontainercol, :root.blender .genddate { - color: var(--blender-text); - border-color: var(--blender-tab-outline); + color: var(--blender-text); + border-color: var(--blender-tab-outline); } -:root.blender .gtaskname>div, +:root.blender .gtaskname > div, :root.blender .gtaskname div, :root.blender .gtaskheading div, :root.blender .gtaskname span { - color: var(--blender-text); + color: var(--blender-text); } :root.blender .gantt-chart tr:not(.gname.glineitem):not(.gname.ggroupitem) { - background-color: var(--blender-odd-row); + background-color: var(--blender-odd-row); } :root.blender .gtasklist, :root.blender .gadditional { - border: var(--blender-tab-outline) 1px solid; + border: var(--blender-tab-outline) 1px solid; } :root.blender .glistgrid, :root.blender .glistlbl { - /* This is a border that needs background color, somehow. */ - background-color: var(--blender-tab-outline); - border-color: var(--blender-tab-outline); + /* This is a border that needs background color, somehow. */ + background-color: var(--blender-tab-outline); + border-color: var(--blender-tab-outline); } - /* Highlight row */ :root.blender .gitemhighlight td, :root.blender .gitemhighlight .gantt-inputtable, :root.blender .gitemhighlight .gcaption, :root.blender .gitemhighlight .ggroupcaption { - background-color: var(--blender-active-highlight) !important; - color: var(--blender-active-text-highlight) !important; + background-color: var(--blender-active-highlight) !important; + color: var(--blender-active-text-highlight) !important; } /* Differentiate Group, Milestone and Ordinary task items (applied to row) */ :root.blender .ggroupitem { - font-weight: bold; - border-color: var(--border-color); + font-weight: bold; + border-color: var(--border-color); } :root.blender .gmileitem, :root.blender .glineitem { - background-color: var(--blender-odd-row); + background-color: var(--blender-odd-row); } /* Task bar caption text styles */ :root.blender .gmilecaption, :root.blender .ggroupcaption, :root.blender .gcaption { - color: var(--blender-text); + color: var(--blender-text); } /* Milestones */ :root.blender .gmdtop, :root.blender .gmdbottom { - border-bottom: 5px solid var(--blender-tab-outline); + border-bottom: 5px solid var(--blender-tab-outline); } :root.blender .gfoldercollapse { - color: var(--blender-text); - cursor: pointer; - font-weight: bold; - font-size: var(--folder-collapse-font-size); - font-family: var(--folder-collapse-font-family); + color: var(--blender-text); + cursor: pointer; + font-weight: bold; + font-size: var(--folder-collapse-font-size); + font-family: var(--folder-collapse-font-family); } /* Highlight for collapsible row */ :root.blender .gname.ggroupitem { - background-color: var(--blender-panel-background); + background-color: var(--blender-panel-background); } /* Form label and selected highlighting */ :root.blender .gformlabel { - background-color: var(--blender-panel-header); - color: var(--blender-text); - border: var(--blender-tab-outline) 1px solid; + background-color: var(--blender-panel-header); + color: var(--blender-text); + border: var(--blender-tab-outline) 1px solid; } :root.blender span.gformlabel:hover, :root.blender span.gselected { - background-color: var(--blender-active-highlight); - color: var(--blender-active-text-highlight); - border-color: var(--blender-tab-outline); + background-color: var(--blender-active-highlight); + color: var(--blender-active-text-highlight); + border-color: var(--blender-tab-outline); } :root.blender .gantt-inputtable { - background-color: var(--blender-panel-header); - box-sizing: border-box; - border: 2px solid var(--blender-button-border); - color: var(--blender-text); + background-color: var(--blender-panel-header); + box-sizing: border-box; + border: 2px solid var(--blender-button-border); + color: var(--blender-text); } /* Dark mode scrollbar */ :root.blender .frame::-webkit-scrollbar-thumb, :root.blender .frame::-webkit-scrollbar-track { - background-color: var(--blender-window-background); -} \ No newline at end of file + background-color: var(--blender-window-background); +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/index.css b/src/bonsai/bonsai/bim/data/webui/static/css/index.css index 02cb6cf3e4..3cc7f9126a 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/index.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/index.css @@ -1,230 +1,16 @@ -:root { - --font-family: Arial, sans-serif; - --base-font-size: 16px; - --margin-tiny: 0.125rem; - --margin-small: 0.625rem; - --margin-medium: 1.25rem; - --margin-large: 2.5rem; - --padding-tiny: 0.125rem; - --padding-small: 0.625rem; - --padding-medium: 1rem; - --font-size-large: 1.2rem; - --logo-height: 1.5rem; - --nav-height: 0.625rem; - --border-radius: 0.3rem; -} - -:root.dark { - color-scheme: dark; - --bg-color: #252525; - --text-color: #e0e0e0; - --nav-bg-color: #121212; - --nav-border-color: #25682a; - --nav-link-color: #fff; - --nav-link-hover-color: #3fb449; - --warning-color: #FFDB8F; - --border-color: #464444; -} - -:root.light { - color-scheme: light; - --bg-color: #ffffff; - --text-color: #000000; - --nav-bg-color: #f8f8f8; - --nav-border-color: #cccccc; - --nav-link-color: #000000; - --nav-link-hover-color: #38a63d; - --warning-color: #FF4500; - --border-color: #222; -} - -*:not(.tabulator-cell) { - border-radius: var(--border-radius) !important; -} - -html { - font-size: var(--base-font-size); -} - -body { - background-color: var(--blender-window-background, var(--bg-color)); - color: var(--blender-text, var(--text-color)); - margin: 0; - font-family: var(--font-family); - display: flex; - flex-direction: column; - min-height: 100vh; -} - -#container { - position: relative; - padding: var(--margin-small); - flex: 1; -} - -h3 { - margin-top: 0; -} - -nav { - background-color: var(--blender-top-bar-header, var(--nav-bg-color)); - height: var(--nav-height); - padding: var(--padding-medium) 0; - display: flex; - align-items: center; - position: relative; - border-bottom: 2px solid var(--blender-tab-outline, var(--nav-border-color)); -} - -nav .logo { - margin-left: var(--margin-large); - height: var(--logo-height); -} - -nav ul { - list-style-type: none; - margin: 0; - display: flex; - flex: 1; - justify-content: center; -} - -nav ul li { - margin-right: var(--margin-large); -} - -nav ul li a { - text-decoration: none; - color: var(--blender-text, var(--nav-link-color)); -} - -nav ul li a:hover, -nav ul li a.active { - color: var(--blender-selected-object, var(--nav-link-hover-color)); -} - -.warning, -.warning-icon { - color: var(--blender-info-warning, var(--warning-color)); -} - -#toggle-theme { - border: none; - background: none; - cursor: pointer; - font-size: var(--font-size-large); - margin-right: var(--margin-medium); -} - -#toggle-theme:focus { - outline: none; -} - -#client-list { - position: absolute; - top: calc(0 + var(--nav-height)); - overflow-y: auto; - overflow: hidden; - transition: opacity 0.3s ease-out, visibility 0.3s ease-out; - border: 1px solid var(--blender-tab-outline, var(--border-color)); - background-color: var(--blender-panel-background, var(--nav-bg-color)); - z-index: 1; - opacity: 0; - visibility: hidden; - width: auto; -} - -#client-list.show { - opacity: 1; - visibility: visible; -} - -#connected-list-div { - display: inline-block; -} - -.client { - padding: var(--margin-small); - border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); - cursor: pointer; -} - -.client-details { - max-height: 0; - overflow: hidden; - padding-left: var(--padding-small); - transition: max-height 0.2s ease-out, padding 0.2s ease-out, opacity 0.1s ease-out; - background-color: var(--blender-panel-background, var(--nav-bg-color)); - margin-top: var(--margin-tiny); - opacity: 0; -} - -.client-details.show { - max-height: none; - opacity: 1; -} - -.client-detail { - border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); - padding: var(--padding-small); -} - -#show-connected-button { - width: auto; - background-color: var(--blender-panel-header, var(--bg-color)); - font-size: var(--base-font-size); - color: var(--blender-text, var(--text-color)) -} - -#show-connected-button:hover, -.scroll-button:hover { - cursor: pointer; -} - -.scroll-button { - font-size: var(--base-font-size); - margin-left: var(--margin-tiny); - margin-top: var(--margin-small); -} - -footer { - background-color: var(--blender-top-bar-header, var(--nav-bg-color)); - text-align: right; - padding: var(--padding-tiny); - border-top: 1px solid var(--blender-tab-outline, var(--nav-border-color)); -} - -footer p { - margin: 0; - color: var(--blender-text, var(--nav-link-color)); - margin-right: var(--margin-small); - font-size: 0.8rem; -} +@import url("./shared.css"); .table-container { - position: absolute; display: block; - height: calc(100% - var(--margin-small) * 2); - width: calc(100% - var(--margin-small) * 2); - overflow-x: scroll; + height: calc(100vh - var(--margin-small)); + width: 100%; + margin-bottom: var(--margin-medium); } .csv-table { margin-top: var(--margin-small); } -button, -select { - background-color: var(--blender-button-background, var(--bg-color)); - color: var(--blender-button-text, var(--text-color)); - border-color: var(--blender-button-border, var(--border-color)); - transition: filter 0.2s ease; -} - -button:hover { - filter: brightness(1.2); -} - /* ------------ Overwriting Tabulator CSS rules ------------ */ :root.light .tabulator-header, @@ -279,24 +65,28 @@ button:hover { color: var(--blender-text); } - - -:root.blender .tabulator-selected .tabulator-cell { +:root.blender .tabulator-selected .tabulator-cell, +:root.blender .tabulator-row.tabulator-selected { background-color: var(--blender-active-highlight) !important; color: var(--blender-active-text-highlight) !important; } - -:root.blender .tabulator-selected:hover .tabulator-cell { +:root.blender .tabulator-selected:hover .tabulator-cell, +:root.blender .tabulator-row.tabulator-selected:hover { filter: brightness(1.2) } -.tabulator-menu-item { +:root.blender .tabulator-popup-container { + background: var(--blender-panel-background); + border: var(--blender-tab-outline); +} + +:root.blender .tabulator-menu-item { color: var(--blender-text) !important; background-color: var(--blender-panel-background) !important; transition: filter 0.1s ease; } -.tabulator-menu-item:hover { +:root.blender .tabulator-menu-item:hover { filter: brightness(1.4); -} +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/shared.css b/src/bonsai/bonsai/bim/data/webui/static/css/shared.css new file mode 100644 index 0000000000..a35a5f0663 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/shared.css @@ -0,0 +1,239 @@ +:root { + --font-family: Arial, sans-serif; + --base-font-size: 16px; + --margin-tiny: 0.125rem; + --margin-small: 0.625rem; + --margin-medium: 1.25rem; + --margin-large: 2.5rem; + --padding-tiny: 0.125rem; + --padding-small: 0.625rem; + --padding-medium: 1rem; + --font-size-small: 1rem; + --font-size-large: 1.2rem; + --logo-height: 1.5rem; + --nav-height: 0.625rem; + --border-radius: 0.3rem; + --folder-collapse-font-size: 0.75rem; + --folder-collapse-font-family: Courier, "Courier New", monospace; + --box-shadow: 0 0 0.7rem #5f5f5f66; +} + +:root.dark { + color-scheme: dark; + --bg-color: #252525; + --primary-text-color: #e0e0e0; + --secondary-text-color: #c7c7c7; + --nav-bg-color: #121212; + --nav-border-color: #25682a; + --nav-link-color: #fff; + --nav-link-hover-color: #3fb449; + --warning-color: #ffdb8f; + --border-color: #464444; + --hover-bg-color: #3a3a3a; + --highlight-color: #009136; + --task-complete-bg-color: #777777; + --milestone-border-color: #ffffff; + --group-item-bg-color: #4b4b4b; + --input-bg-color: #3b3b3b; + --input-border-color: #000; + --border-color: #464444; +} + +:root.light { + color-scheme: light; + --bg-color: #ffffff; + --primary-text-color: #000000; + --nav-bg-color: #f8f8f8; + --nav-border-color: #cccccc; + --nav-link-color: #000000; + --nav-link-hover-color: #38a63d; + --warning-color: #ff4500; + --border-color: #222; +} + +*:not(td, tr, td, th, button, .gmain) { + border-radius: var(--border-radius) !important; +} + +html { + font-size: var(--base-font-size); +} + +body { + background-color: var(--blender-window-background, var(--bg-color)); + color: var(--blender-text, var(--primary-text-color)); + margin: 0; + font-family: var(--font-family); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +#container { + flex: 1; + padding: var(--margin-small); +} + +h3 { + margin-top: 0; +} + +nav { + background-color: var(--blender-top-bar-header, var(--nav-bg-color)); + height: var(--nav-height); + padding: var(--padding-medium) 0; + display: flex; + align-items: center; + position: relative; + border-bottom: 2px solid var(--blender-tab-outline, var(--nav-border-color)); +} + +nav .logo { + margin-left: var(--margin-large); + height: var(--logo-height); +} + +nav ul { + list-style-type: none; + margin: 0; + display: flex; + flex: 1; + justify-content: center; +} + +nav ul li { + margin-right: var(--margin-large); +} + +nav ul li a { + text-decoration: none; + color: var(--blender-text, var(--nav-link-color)); +} + +nav ul li a:hover, +nav ul li a.active { + color: var(--blender-selected-object, var(--nav-link-hover-color)); +} + +.warning, +.warning-icon { + color: var(--blender-info-warning, var(--warning-color)); +} + +#toggle-theme:focus { + outline: none; +} + +#client-list { + position: absolute; + top: calc(0 + var(--nav-height)); + overflow-y: auto; + overflow: hidden; + transition: opacity 0.3s ease-out, visibility 0.3s ease-out; + border: 1px solid var(--blender-tab-outline, var(--border-color)); + background-color: var(--blender-panel-background, var(--nav-bg-color)); + z-index: 1; + opacity: 0; + visibility: hidden; + width: auto; +} + +#client-list.show { + opacity: 1; + visibility: visible; +} + +#connected-list-div { + display: inline-block; +} + +.client { + padding: var(--margin-small); + border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); + cursor: pointer; +} + +.client-details { + max-height: 0; + overflow: hidden; + padding-left: var(--padding-small); + transition: max-height 0.2s ease-out, padding 0.2s ease-out, + opacity 0.1s ease-out; + background-color: var(--blender-panel-background, var(--nav-bg-color)); + margin-top: var(--margin-tiny); + opacity: 0; +} + +.client-details.show { + max-height: none; + opacity: 1; +} + +.client-detail { + border-bottom: 1px solid var(--blender-tab-outline, var(--border-color)); + padding: var(--padding-small); +} + +#show-connected-button { + width: auto; + background-color: var(--blender-panel-header, var(--bg-color)); + font-size: var(--base-font-size); + color: var(--blender-text, var(--text-color)); +} + +#show-connected-button:hover, +.scroll-button:hover { + cursor: pointer; +} + +.scroll-button { + font-size: var(--base-font-size); + margin-left: var(--margin-tiny); + margin-top: var(--margin-small); +} + +footer { + background-color: var(--blender-top-bar-header, var(--nav-bg-color)); + text-align: right; + padding: var(--padding-tiny); + border-top: 1px solid var(--blender-tab-outline, var(--nav-border-color)); +} + +footer p { + margin: 0; + color: var(--blender-text, var(--nav-link-color)); + margin-right: var(--margin-small); + font-size: 0.8rem; +} + +button, +select { + background-color: var(--blender-button-background, var(--bg-color)); + color: var(--blender-button-text, var(--text-color)); + border-color: var(--blender-button-border, var(--border-color)); + transition: filter 0.2s ease; +} + +button:hover { + filter: brightness(1.2); +} + +.row-container { + border-collapse: collapse; + display: flex; + justify-content: flex-start; + flex-wrap: nowrap; + align-items: center; +} + +.column-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + flex-wrap: wrap; +} + +label { + margin-right: var(--margin-small); + margin-bottom: var(--margin-small); +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js new file mode 100644 index 0000000000..ca683e157f --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -0,0 +1,509 @@ +import { CostUI } from "./utilities/costui.js"; + +const connectedClients = {}; +let socket; + +$(document).ready(function () { + var defaultTheme = "blender"; + var theme = localStorage.getItem("theme") || defaultTheme; + setTheme(theme); + + connectSocket(); + CostUI.createRibbon(); + CostUI.addShortcuts(); +}); + +function connectSocket() { + const url = "ws://localhost:" + SOCKET_PORT + "/web"; + socket = io(url); + + socket.on("blender_connect", handleBlenderConnect); + socket.on("blender_disconnect", handleBlenderDisconnect); + socket.on("connected_clients", handleConnectedClients); + socket.on("theme_data", handleThemeData); + socket.on("connect", handleWebConnect); + socket.on("predefined_types", handlePredefinedTypes); + socket.on("cost_schedules", handleCostSchedulesData); + socket.on("cost_items", handleCostItemsData); + socket.on("cost_values", handleCostValuesData); + socket.on("cost_value", handleCostValueData); + socket.on("quantities", handleEditQuantities); + socket.on("classification", handleClassificationData); + socket.on("message", handleMessage); +} + +function handleEditQuantities(data) { + const costItemId = data.data["quantities"]["cost_item_id"]; + const products = data.data["quantities"]["selected_products"]; + const assigned_products = data.data["quantities"]["assigned_products"]; + const quantityNames = data.data["quantities"]["product_quantity_names"]; + const costQuantities = data.data["quantities"]["cost_quantities"]; + + CostUI.enableEditingQuantities({ + costItemId: costItemId, + selectedProducts: products, + assignedProducts: assigned_products, + quantityNames: quantityNames, + costQuantities: costQuantities, + callbacks: { + addProductAssignments: addProductAssignments, + enableEditingQuantities: enableEditingQuantities, + addQuantity: addQuantity, + editQuantity: editQuantity, + deleteQuantity: deleteQuantity, + assignFromQuery: assignFromQuery, + }, + }); +} + +function assignCostValues(costRateId, callback){ + const selectedCostItems = CostUI.getSelectedCostItems(); + if (selectedCostItems.length === 0) { + return; + } + executeOperator({ + type: "assignCostValues", + costRateId: costRateId, + costItemIds: selectedCostItems, + }); +} + + +function addSumCostValue(costItemId) { + executeOperator({ type: "addSumCostValue", costItemId: costItemId }); +} + +function assignFromQuery(costItemId, query, propName) { + executeOperator({ + type: "assignFromQuery", + costItemId: costItemId, + query: query, + propName: propName, + }); +} + +function addQuantity(costItemId, ifcClass) { + executeOperator({ + type: "AddCostItemQuantity", + costItemId: costItemId, + ifcClass: ifcClass, + }); +} + +function editQuantity(costItemId, quantityId, attributes) { + executeOperator({ + type: "editCostItemQuantity", + costItemId: costItemId, + quantityId: quantityId, + attributes: attributes, + }); +} + +function deleteQuantity(costItemId, quantityId) { + executeOperator({ + type: "deleteCostItemQuantity", + costItemId: costItemId, + quantityId: quantityId, + }); +} + +function handlePredefinedTypes(data) { + CostUI.enableAddingCostSchedule( + data.data["predefined_types"], + addCostSchedule + ); +} + +function addProductAssignments({ costItemId, selectedQuantity, productIds }) { + executeOperator({ + type: "addProductAssignments", + costItemId: costItemId, + propName: selectedQuantity, + productIds: productIds, + }); +} + +function addCostSchedule(name, predefinedType) { + executeOperator({ + type: "addCostSchedule", + name: name, + predefinedType: predefinedType, + }); +} + +function handleBlenderConnect(blenderId) { + if (!connectedClients.hasOwnProperty(blenderId)) { + connectedClients[blenderId] = { shown: false, ifc_file: "" }; + } + + $("#blender-count").text(function (i, text) { + return parseInt(text, 10) + 1; + }); +} + +function handleBlenderDisconnect(blenderId) { + if (connectedClients.hasOwnProperty(blenderId)) { + delete connectedClients[blenderId]; + removeTableElement(blenderId); + } + + $("#blender-count").text(function (i, text) { + return parseInt(text, 10) - 1; + }); +} + +function removeTableElement(blenderId) { + $("#cost-items-" + blenderId).remove(); +} + +const costValueCallbacks = { + editCostValues: editCostValues, + addCostValue: addCostValue, + deleteCostValue: deleteCostValue, +}; + +function handleCostValueData(data) { + const costItemId = data.data["cost_value"]["cost_item_id"]; + const costValueId = data.data["cost_value"]["cost_value_id"]; + CostUI.addNewCostValueRow(costItemId, costValueId, costValueCallbacks); +} + +function deleteCostValue(costItemId, costValueId) { + executeOperator({ + type: "deleteCostValue", + costItemId: costItemId, + costValueId: costValueId, + }); +} + +function handleConnectedClients(data) { + $("#blender-count").text(data.length); + + data.forEach(function (id) { + connectedClients[id] = { shown: false, ifc_file: "" }; + }); +} + +function handleThemeData(themeData) { + function arrayToRgbString(arr) { + const [r, g, b, a] = arr.map((num) => Math.round(num * 255)); + if (a !== undefined) { + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + return `rgb(${r}, ${g}, ${b})`; + } + + function generateCssVariableRule(theme) { + let cssVariables = ":root.blender {\n"; + for (const key in theme) { + const cssVariableName = `--blender-${key.replace(/_/g, "-")}`; + const cssVariableValue = arrayToRgbString(theme[key]); + cssVariables += ` ${cssVariableName}: ${cssVariableValue};\n`; + } + cssVariables += "}"; + return cssVariables; + } + + const cssRule = generateCssVariableRule(themeData.theme); + + var styleElement = $("#index-stylesheet")[0]; + if (styleElement) { + var sheet = styleElement.sheet || styleElement.styleSheet; + sheet.insertRule(cssRule, sheet.cssRules.length); + } +} + +function setTheme(theme) { + $("html").removeClass("light dark blender").addClass(theme); + $(":root").css("color-scheme", theme); + if (theme === "light") { + $("#toggle-theme").html(''); + } else if (theme === "dark") { + $("#toggle-theme").html(''); + } else if (theme === "blender") { + $("#toggle-theme").html(''); + } + localStorage.setItem("theme", theme); +} + +function toggleClientList() { + var clientList = $("#client-list"); + + if (clientList.hasClass("show")) { + clientList.removeClass("show"); + return; + } + + clientList.empty(); + var counter = 0; + + $.each(connectedClients, function (id, client) { + counter++; + + const dropdownIcon = $("") + .addClass("fas fa-chevron-down") + .css("margin-left", "0.5rem"); + + const clientDiv = $("
") + .addClass("client") + .text(client.ifc_file || "Blender " + counter); + + clientDiv.append(dropdownIcon); + + const clientDetailsDiv = $("
").addClass("client-details"); + + if (id) { + const clientId = $("
") + .addClass("client-detail") + .text(`Blender ID: ${id}`); + clientDetailsDiv.append(clientId); + } + + if (!client.has_drawings) { + const clientShown = $("
") + .addClass("client-detail") + .text("No drawings exist for this IFC File yet"); + clientDetailsDiv.append(clientShown); + } else { + const clientNumbers = $("
") + .addClass("client-detail") + .text( + `${allDrawings[client.ifc_file].drawings.length} Drawing(s), + ${allDrawings[client.ifc_file].sheets.length} Sheet(s)` + ); + clientDetailsDiv.append(clientNumbers); + } + + clientDiv.append(clientDetailsDiv); + + clientDiv.on("click", function () { + clientDetailsDiv.toggleClass("show"); + }); + + clientList.append(clientDiv); + }); + clientList.addClass("show"); +} + +function handleCostSchedulesData(data) { + const blenderId = data.blenderId; + const costSchedules = data.data["cost_schedules"]["cost_schedules"]; + if (costSchedules.length === 0) { + return; + } + const costScheduleDiv = document.getElementById("cost-schedules"); + costScheduleDiv.innerHTML = ""; + costSchedules.forEach((costSchedule) => { + const predefinedType = CostUI.Text( + "Predefined Type: " + costSchedule.PredefinedType, + "fa-solid fa-paperclip" + ); + const updatedDate = new Date(costSchedule.UpdateDate).toLocaleDateString(); + const updatedOn = CostUI.Text( + "Updated On: " + updatedDate, + "fa-solid fa-pen-nib" + ); + const div = document.createElement("div"); + + div.appendChild(predefinedType); + div.appendChild(updatedOn); + const callback = () => loadCostSchedule(costSchedule.id, blenderId); + + const card = CostUI.createCard( + costSchedule.id, + costSchedule.Name, + div, + callback + ); + costScheduleDiv.append(card); + }); +} + +function handleCostItemsData(data) { + const costScheduleId = data.data["cost_items"]["schedule_data"][0]["id"]; + const currency = data.data["cost_items"]["currency"] + ? data.data["cost_items"]["currency"]["name"] + : "Undefined"; + const costSchedule = data.data["cost_items"]["schedule_data"][0]; + CostUI.createCostSchedule({ + costSchedule: costSchedule, + currency: currency, + callbacks: { + addCostItem: addCostItem, + deleteCostItem: deleteCostItem, + duplicateCostItem: duplicateCostItem, + selectAssignedElements: selectAssignedElements, + editCostItemName: editCostItemName, + enableEditingCostValues: enableEditingCostValues, + addSummaryCostItem: addSummaryCostItem, + enableEditingQuantities: enableEditingQuantities, + addSumCostValue: addSumCostValue, + enableEditingClassification: enableEditingClassification, + assignCostValues: assignCostValues, + }, + }); +} + +function addCostItem(costItemId) { + executeOperator({ type: "addCostItem", costItemId: costItemId }); +} + +function editCostItemName(costItemId, name) { + executeOperator({ + type: "editCostItemName", + costItemId: costItemId, + name: name, + }); +} + +function selectAssignedElements(costItemId) { + executeOperator({ type: "selectAssignedElements", costItemId: costItemId }); +} + +function handleWebConnect() { + executeOperator({ type: "getPredefinedTypes" }); + getCostSchedules(); +} + +function addSummaryCostItem(costScheduleId) { + executeOperator({ + type: "addSummaryCostItem", + costScheduleId: costScheduleId, + }); +} + +function loadCostSchedule(costScheduleId, blenderId) { + executeOperator( + { type: "loadCostSchedule", costScheduleId: costScheduleId }, + blenderId + ); +} + +function getCostSchedules(blenderId) { + executeOperator({ type: "getCostSchedules" }, blenderId); +} + +function enableEditingCostValues(costItemId) { + executeOperator({ type: "enableEditingCostValues", costItemId: costItemId }); +} + +function enableEditingClassification(costItemId) { + executeOperator({ + type: "enableEditingClassification", + costItemId: costItemId, + }); +} + +function removeClassificationReference(costItemId, classificationId) { + executeOperator({ + type: "removeClassificationReference", + costItemId: costItemId, + classificationId: classificationId, + }); +} + +function addClassificationReference( + costItemId, + classificationName, + classificationId +) { + executeOperator({ + type: "addClassificationReference", + costItemId: costItemId, + classificationName: classificationName, + classificationId: classificationId, + }); +} + +function handleClassificationData(data) { + const costClassifications = + data.data["classification"]["cost_classifications"]; + const classificationElements = + data.data["classification"]["classification_data"]; + const classificationName = data.data["classification"]["classification_name"]; + const costItemId = data.data["classification"]["cost_item_id"]; + + CostUI.createCostClassificationWindow({ + classificationName, + classificationElements, + costClassifications, + costItemId, + callbacks: { + enableEditingClassification: enableEditingClassification, + removeClassificationReference: removeClassificationReference, + addClassificationReference: addClassificationReference, + }, + }); +} + +function enableEditingQuantities(costItemId) { + executeOperator({ type: "enableEditingQuantities", costItemId: costItemId }); +} + +function duplicateCostItem(costItemId) { + executeOperator({ type: "duplicateCostItem", costItemId: costItemId }); +} + +function handleCostValuesData(data) { + CostUI.createCostValuesForm({ + costValues: data.data["cost_values"]["cost_values"], + costItemId: data.data["cost_values"]["cost_item_id"], + callbacks: { + editCostValues: editCostValues, + addCostValue: addCostValue, + deleteCostValue: deleteCostValue, + }, + }); + + CostUI.highlightElement(data.data["cost_values"]["cost_item_id"]); +} + +function addCostValue(costItemId) { + executeOperator({ type: "addCostValue", costItemId: costItemId }); +} + +function editCostValues(costItemId, costValues) { + executeOperator({ + type: "editCostValues", + costItemId: costItemId, + costValues: costValues, + }); +} + +function deleteCostItem(costItemId) { + CostUI.deleteCostItem(costItemId); + executeOperator({ type: "deleteCostItem", costItemId: costItemId }); +} + +function handleMessage(data) { + if (data.data["message"].hasOwnProperty("error")) { + handleError(data); + } else { + handleSuccess(data); + } +} + +function handleError(data) { + const errorMessage = data.data["message"]["error"]; + const containerId = data.data["message"]["container"]; + const container = document.getElementById(containerId); + CostUI.Error(container, errorMessage); +} + +function handleSuccess(data) { + const successMessage = data.data["message"]["success"]; + const containerId = data.data["message"]["container"]; + const container = document.getElementById(containerId); + CostUI.Success(container, successMessage); +} + +function executeOperator(operator, blenderId) { + const msg = { + sourcePage: "cost", + operator: operator, + }; + if (blenderId !== undefined) { + msg.BlenderId = blenderId; + } + socket.emit("web_operator", msg); +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/drawings.js b/src/bonsai/bonsai/bim/data/webui/static/js/drawings.js index c23215f285..6fb929a228 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/drawings.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/drawings.js @@ -29,7 +29,7 @@ function connectSocket() { socket.on("blender_disconnect", handleBlenderDisconnect); socket.on("connect", handleWebConnect); socket.on("connected_clients", handleConnectedClients); - socket.on("default_data", handleDefaultData); + //socket.on("default_data", handleDefaultData); socket.on("theme_data", handleThemeData); socket.on("drawings_data", handleDrawingsData); socket.on("svg_data", handleSvgData); @@ -37,6 +37,7 @@ function connectSocket() { // function used to get drawings data from Bonsai function handleWebConnect() { + console.log("Handle Web Connect") getDrawingsData(); } @@ -73,7 +74,6 @@ function handleDefaultData(data) { // Function to handle 'blender_disconnect' event function handleBlenderDisconnect(blenderId) { - console.log("blender disconnected: ", blenderId); if (connectedClients.hasOwnProperty(blenderId)) { delete connectedClients[blenderId]; removeSelectOption(blenderId); @@ -97,8 +97,6 @@ function handleConnectedClients(data) { } function handleThemeData(themeData) { - // console.log(themeData); - function arrayToRgbString(arr) { const [r, g, b, a] = arr.map((num) => Math.round(num * 255)); if (a !== undefined) { @@ -129,6 +127,7 @@ function handleThemeData(themeData) { } function handleDrawingsData(data) { + console.log("Running handleDrawingsData"); const blenderId = data["blenderId"]; console.log(data); diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js index 9531aa5ecb..5eaaffa841 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js @@ -1,3 +1,5 @@ +import { CostUI } from './utilities/costui.js'; + // keeps track of blenders connected in form of // shown:bool, workSchedule: {}, ganttTasks: {}, warning: bool const connectedClients = {}; @@ -30,20 +32,25 @@ $(document).ready(function () { function connectSocket() { const url = "ws://localhost:" + SOCKET_PORT + "/web"; socket = io(url); - console.log("socket: ", socket); // Register socket event handlers socket.on("blender_connect", handleBlenderConnect); socket.on("blender_disconnect", handleBlenderDisconnect); socket.on("connected_clients", handleConnectedClients); + socket.on("connect", handleWebConnect); socket.on("theme_data", handleThemeData); socket.on("gantt_data", handleGanttData); socket.on("default_data", handleDefaultData); + socket.on("work_schedule_info", handleWorkScheduleData); +} + +// function used to get drawings data from Bonsai +function handleWebConnect() { + getWorkScheduleData(); } // Function to handle 'blender_connect' event function handleBlenderConnect(blenderId) { - console.log("blender connected: ", blenderId); if (!connectedClients.hasOwnProperty(blenderId)) { connectedClients[blenderId] = { shown: false, @@ -59,9 +66,9 @@ function handleBlenderConnect(blenderId) { // Function to handle 'blender_disconnect' event function handleBlenderDisconnect(blenderId) { - console.log("blender disconnected: ", blenderId); if (connectedClients.hasOwnProperty(blenderId)) { delete connectedClients[blenderId]; + removeWorkScheduleInfo(blenderId); removeGanttElement(blenderId); } @@ -72,7 +79,6 @@ function handleBlenderDisconnect(blenderId) { function handleConnectedClients(data) { $("#blender-count").text(data.length); - // console.log(data); data.forEach(function (id) { connectedClients[id] = { shown: false, @@ -83,8 +89,6 @@ function handleConnectedClients(data) { } function handleThemeData(themeData) { - // console.log(themeData); - function arrayToRgbString(arr) { const [r, g, b, a] = arr.map((num) => Math.round(num * 255)); if (a !== undefined) { @@ -105,7 +109,6 @@ function handleThemeData(themeData) { } const cssRule = generateCssVariableRule(themeData.theme); - console.log(cssRule); var styleElement = $("#gantt-stylesheet")[0]; if (styleElement) { @@ -114,12 +117,28 @@ function handleThemeData(themeData) { } } -// Function to handle 'gantt_data' event +function handleWorkScheduleData(data) { + const blenderId = data["blenderId"]; + const workSchedulesMainDiv = $("#work_schedules"); + const workSchedulesDiv = $("
").attr("id", "work_schedules-" + blenderId); + workSchedulesMainDiv.append(workSchedulesDiv); + + const workSchedules = data["data"]["work_schedule_info"]; + + workSchedules.forEach((workSchedule) => { + const mainContainer = CostUI.Text( + "Created On: " + new Date(workSchedule.CreationDate).toLocaleDateString(), + "fa-solid fa-pen-nib" + ); + const callback = () => loadWorkSchedule(workSchedule.id); + const card = CostUI.createCard(workSchedule.id, workSchedule.Name,mainContainer, callback); + workSchedulesDiv.append(card); + }); +} + function handleGanttData(data) { const blenderId = data["blenderId"]; - console.log(data); - const filename = data["data"]["ifc_file"]; const ganttTasks = data["data"]["gantt_data"]["tasks"]; const ganttWorkSched = data["data"]["gantt_data"]["work_schedule"]; @@ -156,7 +175,6 @@ function handleDefaultData(data) { const blenderId = data["blenderId"]; const isDirty = data["data"]["is_dirty"]; showWarning(blenderId, isDirty); - console.log(data); } // Function to add a new gantt with data and filename @@ -428,7 +446,6 @@ function editValue(list, task, event, cell, column) { value: event.target.value, }, }; - console.log("web operator: " + msg); socket.emit("web_operator", msg); } @@ -551,3 +568,33 @@ function toggleClientList() { clientList.addClass("show"); } + +function loadWorkSchedule(workScheduleId) { + const msg = { + sourcePage: "gantt", + operator: { + type: "loadWorkSchedule", + workScheduleId: workScheduleId, + }, + }; + socket.emit("web_operator", msg); +} + +function removeWorkScheduleInfo(blenderId) { + $("#work_schedules-"+ blenderId).empty(); +} + +function getWorkScheduleData(blenderId) { + const msg = { + sourcePage: "gantt", + operator: { + type: "getWorkSchedules", + }, + }; + + if (blenderId !== undefined) { + msg.BlenderId = blenderId; + } + + socket.emit("web_operator", msg); +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/index.js b/src/bonsai/bonsai/bim/data/webui/static/js/index.js index 3fac376f28..d4b6d7fb73 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/index.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/index.js @@ -207,8 +207,35 @@ function addTableElement(blenderId, csvData, filename) { return menu; } - var containerHeight=$('.table-container').height(); - var h3Height=$('.table-container>h3').height(); + // create right click context menu for choosing column top calculation + function createColumnContextMenu(column) { + const field = column.field; + const calculations = ["sum", "avg", "max", "min"]; + + // label is the item label in the menu + // update column defination on clicking an item + // specifically the topCalc and pass calc type to the topCalcFormatter function + // uisng the topCalcFormatterParams field + return calculations.map((calc) => ({ + label: `Show ${ + calc.charAt(0).toUpperCase() + calc.slice(1) + } for ${field}`, + action: (e, cell) => + cell + .getColumn() + .updateDefinition({ topCalc: calc, topCalcFormatterParams: calc }), + })); + } + + function calcFormatter(cell, formatterParams, onRendered) { + // formatterParams is anything in the topCalcFormatterParams field in the column definition + // so we pass the calculation type to display what the calculation type is + return `${formatterParams}: ${cell.getValue()}`; + } + + var containerHeight = $(".table-container").height(); + var h3Height = $(".table-container>h3").height(); + var table = new Tabulator("#table-" + blenderId, { placeholder: "No data to display", resizableColumnGuide: true, @@ -222,14 +249,21 @@ function addTableElement(blenderId, csvData, filename) { movableColumns: true, // Our fields are never nested, and we use the "." character in queries nestedFieldSeparator: false, + // defines column defualts that are applied to all columns + columnDefaults: { + visible: true, + headerFilter: true, + topCalc: "sum", + topCalcFormatter: calcFormatter, + topCalcFormatterParams: "sum", + }, + // defines a callback to edit the column definition generated by autoColumns + // that cannot be set as default autoColumnsDefinitions: function (definitions) { - menu = createHeaderMenu(definitions, table); + headerMenu = createHeaderMenu(definitions, table); definitions.forEach((column) => { - column.visible = true; - column.headerFilter = true; - column.headerMenu = menu; - // TODO: more user control to choose function, style at bottomCalc - column.topCalc = 'sum'; + column.headerMenu = headerMenu; + column.contextMenu = createColumnContextMenu(column); }); return definitions; }, @@ -239,7 +273,9 @@ function addTableElement(blenderId, csvData, filename) { .css("margin-left", "10px") .css("cursor", "pointer"); tableTitle.append(downloadCsv); - downloadCsv.on('click', function() { table.download("csv", "data.csv"); }) + downloadCsv.on("click", function () { + table.download("csv", "data.csv"); + }); table.on("rowSelected", function (row) { var index = row.getIndex(); // the guid of the object diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js new file mode 100644 index 0000000000..7c9f5ed1a1 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -0,0 +1,2986 @@ +export class CostUI { + constructor() {} + + static createContextMenu(callbacks) { + const contextMenu = document.createElement("div"); + contextMenu.id = "context-menu"; + contextMenu.classList.add("context-menu"); + contextMenu.innerHTML = ` + + + + + + `; + document.body.appendChild(contextMenu); + + document.addEventListener("contextmenu", function (event) { + event.preventDefault(); + const targetRow = event.target.closest("tr"); + const targetCell = event.target.closest("td"); + if (targetRow) { + const isCostTable = targetRow.parentElement.parentElement + ? targetRow.parentElement.parentElement.id.includes("cost-items") + : false; + if (isCostTable) { + const contextMenu = document.getElementById("context-menu"); + contextMenu.style.display = "block"; + contextMenu.style.left = `${event.pageX}px`; + contextMenu.style.top = `${event.pageY}px`; + contextMenu.targetRow = targetRow; + contextMenu.targetCell = targetCell; + } else { + document.getElementById("context-menu").style.display = "none"; + } + } + }); + + document.addEventListener("click", function (event) { + const contextMenu = document.getElementById("context-menu"); + if (!contextMenu.contains(event.target)) { + contextMenu.style.display = "none"; + } + }); + + const editCostValuesButton = document.getElementById( + "edit-cost-values-button" + ); + const addButton = document.getElementById("add-cost-item-button"); + const deleteCostItemButton = document.getElementById("delete-cost-item"); + const duplicateButton = document.getElementById("duplicate-button"); + const getSelectedProducts = document.getElementById("assign-selection"); + + function handleButtonClick(button, callback) { + if (button && !button.hasListener) { + button.addEventListener("click", function () { + const targetRow = document.getElementById("context-menu").targetRow; + if (targetRow) { + const costItemId = parseInt(targetRow.getAttribute("id")); + callback(costItemId); + } + document.getElementById("context-menu").style.display = "none"; + }); + button.hasListener = true; + } + } + + handleButtonClick(deleteCostItemButton, callbacks.deleteCostItem); + handleButtonClick(duplicateButton, callbacks.duplicateCostItem); + handleButtonClick(getSelectedProducts, callbacks.enableEditingQuantities); + handleButtonClick(editCostValuesButton, callbacks.enableEditingCostValues); + handleButtonClick(addButton, callbacks.addCostItem); + handleButtonClick(deleteCostItemButton, callbacks.deleteCostItem); + } + + static addTableCallbacks(callbacks, table) { + if (table.listenersAdded) { + return; + } + + function getColumnNames(tableId) { + const table = document.getElementById(tableId); + const headerRow = table.querySelector("thead tr"); + return Array.from(headerRow.children).map((headerCell) => + headerCell.textContent.trim() + ); + } + + function addTableListeners(callbacks) { + table.addEventListener("click", function (event) { + const targetRow = event.target.closest("tr"); + const targetCell = event.target.closest("td"); + if (targetRow && targetCell) { + const columnIndex = Array.from(targetRow.children).indexOf( + targetCell + ); + const tableId = table.id; + const costItemId = parseInt(targetRow.getAttribute("id")); + const columnName = getColumnNames(tableId)[columnIndex]; + + if ( + (columnName.includes("Cost") || columnName.includes("Rate")) && + !columnName.includes("Total") && + !columnName.includes("Linked") + ) { + callbacks.enableEditingCostValues + ? callbacks.enableEditingCostValues(costItemId) + : null; + } + + if (columnName === "Quantity") { + callbacks.enableEditingQuantities + ? callbacks.enableEditingQuantities(costItemId) + : null; + } + + if (columnName === "Classification") { + callbacks.enableEditingClassification + ? callbacks.enableEditingClassification(costItemId) + : null; + } + + if (columnName === "Linked Rate") { + if (!targetRow.isRate) { + if (targetCell.classList.contains("selected-cell")) { + document.selectedCostItems = document.selectedCostItems.filter( + (costItem) => costItem !== targetRow.id + ); + targetCell.classList.remove("selected-cell"); + } else { + document.selectedCostItems.push(targetRow.id); + targetCell.classList.add("selected-cell"); + } + } + } + } + }); + } + + if (!document.selectedCostItems) { + document.selectedCostItems = []; + } + document.selectedCostItems = []; + addTableListeners(callbacks); + table.listenersAdded = true; + } + + static addSettingsMenu() { + const settingsMenu = CostUI.Form({ + id: "settings-menu", + name: "Settings", + icon: "fa-solid fa-gear", + shouldHide: true, + }); + + const picker = CostUI.createColorPicker(); + const tableFontSize = CostUI.createFontSizePicker(); + const currencyPicker = CostUI.createCurrencyPicker(); + settingsMenu.appendChild(picker); + settingsMenu.appendChild(tableFontSize); + document.getElementById("settings-menu").style.display = "none"; + CostUI.applySavedSettings(); + const columnSelector = CostUI.createColumnSelector(); + settingsMenu.appendChild(columnSelector); + } + + static createColumnSelector() { + const columnSelector = document.createElement("div"); + columnSelector.id = "column-selector"; + const columns = [ + "Identification", + "Quantity", + "Unit", + "Classification", + "Actions", + "Linked Rate", + ]; + columns.forEach((column) => { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.column = column; + input.checked = true; + label.appendChild(input); + label.appendChild(document.createTextNode(column)); + columnSelector.appendChild(label); + }); + + columnSelector.querySelectorAll("input").forEach((input) => { + input.addEventListener("change", () => { + const preferences = CostUI.getColumnPreferences(); + preferences[input.dataset.column] = input.checked; + CostUI.setColumnPreferences(preferences); + CostUI.updateColumnVisibility(preferences); + }); + }); + return columnSelector; + } + + static setColumnPreferences(preferences) { + localStorage.setItem("columnPreferences", JSON.stringify(preferences)); + } + + static getColumnPreferences() { + const preferences = localStorage.getItem("columnPreferences"); + return preferences + ? JSON.parse(preferences) + : { + Identification: true, + Name: true, + Quantity: true, + Unit: true, + Cost: true, + TotalCost: true, + Classification: true, + CostRate: true, + Actions: true, + }; + } + + static updateColumnVisibility(preferences) { + const tables = document.querySelectorAll("table[id^='cost-items-']"); + tables.forEach((table) => { + const columns = table.querySelectorAll("th, td"); + columns.forEach((column) => { + const columnName = column.getAttribute("data-column"); + if (preferences[columnName] !== undefined) { + column.style.display = preferences[columnName] ? "" : "none"; + } + }); + }); + } + + static addShortcuts() { + document.addEventListener("keydown", (event) => { + if (event.ctrlKey && event.key === "c") { + event.preventDefault(); + CostUI.copySelectedToClipboard(); + } + }); + } + + static createRibbon() { + CostUI.addSettingsMenu(); + + CostUI.addRibbonButton({ + text: "Copy Selected", + icon: "fa-solid fa-copy", + callback: () => { + CostUI.copySelectedToClipboard(); + }, + }); + + CostUI.addRibbonButton({ + text: "Hide Schedules", + icon: "fa-regular fa-eye-slash", + callback: (button) => { + CostUI.toggleSchedulesContainer(button); + }, + }); + CostUI.addRibbonButton({ + text: "Settings", + icon: "fas fa-cog", + callback: () => { + CostUI.toggleSettingsMenu(); + }, + }); + } + + static generateColorScheme(baseColor) { + const levels = 10; + const colorScheme = []; + for (let i = 0; i < levels; i++) { + colorScheme.push(CostUI.lightenColor(baseColor, i * 5)); + } + return colorScheme; + } + + static lightenColor(color, percent) { + const num = parseInt(color.slice(1), 16), + amt = Math.round(2.55 * percent), + R = (num >> 16) + amt, + G = ((num >> 8) & 0x00ff) + amt, + B = (num & 0x0000ff) + amt; + return `#${( + 0x1000000 + + (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + + (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + + (B < 255 ? (B < 1 ? 0 : B) : 255) + ) + .toString(16) + .slice(1) + .toUpperCase()}`; + } + + static applyColorScheme(tableId, colorScheme) { + const rows = document.querySelectorAll(`#${tableId} tbody tr`); + rows.forEach((row, index) => { + const level = index % colorScheme.length; + row.style.backgroundColor = colorScheme[level]; + }); + } + + static enableAddingCostSchedule(types, addCostSchedule) { + const addNewSchedule = document.getElementById("add-cost-schedule"); + + addNewSchedule.addEventListener("click", function () { + const name = "Add New Cost Schedule"; + + const form = CostUI.Form({ + id: "add-cost-schedule-form", + name: name, + icon: "fa-solid fa-money-bill-wave", + }); + + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name:"; + form.appendChild(nameLabel); + form.classList.add("column-container"); + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.name = "name"; + form.appendChild(nameInput); + + const typeLabel = document.createElement("label"); + typeLabel.textContent = "Type:"; + form.appendChild(typeLabel); + + const typeSelect = document.createElement("select"); + typeSelect.name = "Predefined Type"; + + types.forEach((type) => { + const option = document.createElement("option"); + option.value = type.name; + option.textContent = type.name; + option.title = type.description; + typeSelect.appendChild(option); + }); + form.appendChild(typeSelect); + + const submitButton = document.createElement("button"); + submitButton.textContent = "Add"; + submitButton.classList.add("action-button"); + form.appendChild(submitButton); + + document + .getElementById("add-cost-schedule-form") + .addEventListener("submit", function (event) { + event.preventDefault(); + const formData = new FormData( + document.getElementById("add-cost-schedule-form") + ); + const data = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + addCostSchedule(data.name, data["Predefined Type"]); + document.getElementById("add-cost-schedule-form").remove(); + }); + }); + } + + static createColorPicker() { + const div = document.createElement("div"); + div.classList.add("row-container"); + const colorPicker = document.createElement("input"); + colorPicker.type = "color"; + colorPicker.id = "color-picker"; + colorPicker.value = "#ff0000"; + const colorText = document.createElement("p"); + colorText.textContent = "Select a color to change row color:"; + div.appendChild(colorText); + div.appendChild(colorPicker); + + colorPicker.addEventListener("input", function () { + const baseColor = colorPicker.value; + const colorScheme = CostUI.generateColorScheme(baseColor); + CostUI.applyColorScheme("cost-items", colorScheme); + }); + const colorScheme = CostUI.generateColorScheme("#000000"); + CostUI.applyColorScheme("cost-items", colorScheme); + return div; + } + + static createCostSchedule({ costSchedule, currency, callbacks = {} }) { + const [table, tbody] = CostUI.createCostTable({ + costSchedule, + currency, + callbacks, + }); + this.addTableCallbacks(callbacks, table); + if (costSchedule["cost_items"].length === 0) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 6; + td.textContent = "No cost items found"; + tr.appendChild(td); + tbody.appendChild(tr); + + const addSummaryCostItemButton = document.createElement("button"); + addSummaryCostItemButton.textContent = "Add Summary Cost Item"; + addSummaryCostItemButton.addEventListener("click", function () { + callbacks.addSummaryCostItem + ? callbacks.addSummaryCostItem(costSchedule.id) + : null; + }); + td.appendChild(addSummaryCostItemButton); + addSummaryCostItemButton.classList.add("action-button"); + } else { + CostUI.createCostTree({ + costItems: costSchedule["cost_items"], + container: tbody, + nestingLevel: 0, + parentID: null, + callbacks: callbacks, + isScheduleOfRates: costSchedule.PredefinedType === "SCHEDULEOFRATES", + }); + CostUI.applyExpandedState(); + } + } + + static isCostScheduleLoaded(id) { + const existingTable = document.getElementById( + "cost-schedule-container-" + id + ); + return existingTable !== null; + } + + static removeCostSchedule(id) { + const container = document.getElementById("cost-schedule-container-" + id); + container.remove(); + } + + static createCostTree({ + costItems, + container, + nestingLevel, + parentID, + callbacks, + isScheduleOfRates = false, + }) { + costItems.forEach((costItem) => { + const row = CostUI.addCostItemRow( + costItem, + nestingLevel, + parentID, + isScheduleOfRates, + callbacks + ); + container.appendChild(row); + + if (costItem.IsNestedBy && costItem.IsNestedBy.length > 0) { + CostUI.createCostTree({ + costItems: costItem.IsNestedBy, + container: container, + nestingLevel: nestingLevel + 1, + parentID: costItem.id, + isScheduleOfRates: isScheduleOfRates, + callbacks: callbacks, + }); + } + }); + } + + static copySelectedToClipboard() { + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const fragment = range.cloneContents(); + const rows = fragment.querySelectorAll("tr"); + let tsv = ""; + + rows.forEach((row) => { + if (row.classList.contains("nested")) { + return; + } + + const cells = row.querySelectorAll("td, th"); + let rowText = []; + + if (CostUI.getCostItemRow(row.id)) { + const costItem = CostUI.getCostItemRow(row.id); + rowText.push(costItem.nestingLevel); + } else { + rowText.push("Hierarchy Level"); + } + + if (row.id) { + rowText.push(row.id); + } else { + rowText.push("Step ID"); + } + + cells.forEach((cell) => { + let cellText = ""; + if (cell.querySelector("input")) { + cellText = cell.querySelector("input").value; + } else { + cellText = cell.innerText; + } + + cellText = cellText.replace(/[\s\*\-]/g, ""); + rowText.push(cellText); + }); + tsv += rowText.join("\t") + "\n"; + }); + + const textarea = document.createElement("textarea"); + textarea.value = tsv; + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand("copy"); + alert("Selected text copied to clipboard!"); + } catch (err) { + alert("Failed to copy selected text: " + err); + } + + document.body.removeChild(textarea); + } else { + alert("No text selected!"); + } + } + + static createCostTable({ costSchedule, currency, callbacks }) { + const preferences = CostUI.getColumnPreferences(); + const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES"; + let tableWrapper; + const id = costSchedule.id; + if (CostUI.isCostScheduleLoaded(id)) { + const table = document.getElementById("cost-items-" + id); + tableWrapper = table.parentElement; + table.remove(); + } else { + const scheduleDiv = document.createElement("div"); + scheduleDiv.classList.add("schedule"); + scheduleDiv.id = "cost-schedule-container-" + id; + + const scheduleHeader = document.createElement("div"); + scheduleHeader.classList.add("form-header"); + + const scheduleName = CostUI.Text( + costSchedule.Name, + "fa-solid fa-money-bill-wave", + "x-large" + ); + + tableWrapper = document.createElement("div"); + tableWrapper.classList.add("table-wrapper"); + + const callback = () => { + scheduleDiv.remove(); + }; + let closeButton = CostUI.createCloseButton(callback); + + scheduleHeader.appendChild(scheduleName); + scheduleHeader.appendChild(closeButton); + + scheduleDiv.appendChild(scheduleHeader); + scheduleDiv.appendChild(tableWrapper); + document.getElementById("cost-items").appendChild(scheduleDiv); + } + + const table = document.createElement("table"); + table.id = "cost-items-" + id; + const tbody = document.createElement("tbody"); + let columnHeaders = [ + { name: "Identification", visible: preferences.Identification }, + { name: "Name", visible: preferences.Name }, + { name: "Quantity", visible: preferences.Quantity }, + { name: "Unit", visible: preferences.Unit }, + { name: "Cost (" + currency + ")", visible: preferences.Cost }, + { name: "Total Cost (" + currency + ")", visible: preferences.TotalCost }, + { name: "Classification", visible: preferences.Classification }, + { name: "Linked Rate", visible: preferences.CostRate }, + { name: "Actions", visible: preferences.Actions }, + ]; + + if (isScheduleOfRates) { + columnHeaders = columnHeaders.filter( + (header) => header.name !== "Quantity" + ); + columnHeaders = columnHeaders.filter( + (header) => header.name !== "Linked Rate" + ); + columnHeaders = columnHeaders.map((header) => { + if (header.name === "Cost") { + return { name: "Rate (" + currency + ")", visible: preferences.Cost }; + } else if (header.name === "Total Cost (" + currency + ")") { + return { + name: "Total Rate (" + currency + ")", + visible: preferences.TotalCost, + }; + } + return header; + }); + } + const thead = document.createElement("thead"); + const tr = document.createElement("tr"); + thead.appendChild(tr); + + for (let i = 0; i < columnHeaders.length; i++) { + if (columnHeaders[i].visible) { + const th = document.createElement("th"); + th.textContent = columnHeaders[i].name; + th.setAttribute("data-column", columnHeaders[i].name); + tr.appendChild(th); + + if (i < columnHeaders.length - 1) { + const resizer = document.createElement("div"); + resizer.classList.add("resizer"); + th.appendChild(resizer); + CostUI.addResizer(resizer); + } + } + } + + table.appendChild(thead); + table.appendChild(tbody); + tableWrapper.appendChild(table); + + CostUI.createContextMenu(callbacks); + table.get_blender_id = function () { + return this.getAttribute("id").split("-")[2]; + }; + + return [table, tbody]; + } + + static addCostItemRow( + costItem, + nestingLevel, + parentID, + isScheduleOfRates, + callbacks = {} + ) { + const preferences = CostUI.getColumnPreferences(); + const totalQuantity = costItem.TotalCostQuantity + ? CostUI.format_number(costItem.TotalCostQuantity) + : "-"; + let appliedValue = costItem.TotalAppliedValue + ? CostUI.format_number(costItem.TotalAppliedValue) + : "-"; + const unitBasisValue = costItem.UnitBasisValueComponent + ? CostUI.format_number(costItem.UnitBasisValueComponent) + : null; + if (isScheduleOfRates && unitBasisValue) { + appliedValue = appliedValue + " / " + unitBasisValue; + } + const row = CostUI.createCostItemRow(costItem, nestingLevel, parentID); + const identification = costItem.Identification + ? costItem.Identification + : "XXX"; + if (preferences.Identification) { + const idCell = CostUI.createTableCell(identification); + idCell.setAttribute("data-column", "Identification"); + row.appendChild(idCell); + } + + const expandButton = CostUI.createExpandButton(costItem); + if (preferences.Name) { + const nameCell = CostUI.createNameCell( + costItem, + nestingLevel, + expandButton, + callbacks + ); + nameCell.setAttribute("data-column", "Name"); + row.appendChild(nameCell); + } + + if (isScheduleOfRates) { + if (preferences.Unit) { + const unitBasisUnitSymbol = costItem.UnitBasisUnitSymbol + ? costItem.UnitBasisUnitSymbol + : "-"; + const unitBasisUnitSymbolCell = + CostUI.createTableCell(unitBasisUnitSymbol); + unitBasisUnitSymbolCell.setAttribute("data-column", "Unit"); + row.appendChild(unitBasisUnitSymbolCell); + } + } else { + if (preferences.Quantity) { + const totalCostQuantityCell = CostUI.createTableCell(totalQuantity); + totalCostQuantityCell.setAttribute("data-column", "Quantity"); + row.appendChild(totalCostQuantityCell); + totalCostQuantityCell.classList.add("clickable-cell"); + } + if (preferences.Unit) { + let symbol = costItem.UnitSymbol; + if (symbol === "-" && costItem.UnitBasisUnitSymbol) { + symbol = costItem.UnitBasisUnitSymbol; + } + const unitSymbolCell = CostUI.createTableCell(symbol); + unitSymbolCell.setAttribute("data-column", "Unit"); + row.appendChild(unitSymbolCell); + } + } + + if (preferences.Cost) { + const totalAppliedValueCell = CostUI.createCostCell(appliedValue); + totalAppliedValueCell.setAttribute("data-column", "Cost"); + totalAppliedValueCell.classList.add("clickable-cell"); + row.appendChild(totalAppliedValueCell); + } + + if (preferences.TotalCost) { + const totalCostCell = CostUI.createTotalCostCell( + costItem, + isScheduleOfRates, + callbacks.addSumCostValue + ); + totalCostCell.setAttribute("data-column", "Total Cost"); + row.appendChild(totalCostCell); + } + + if (preferences.Classification) { + const classifications = costItem.Classification + ? costItem.Classification + : []; + const ClassificationCell = document.createElement("td"); + ClassificationCell.setAttribute("data-column", "Classification"); + ClassificationCell.classList.add("clickable-cell"); + row.appendChild(ClassificationCell); + const string_List = classifications + .map((classification) => classification.Identification) + .join(", "); + ClassificationCell.textContent = string_List; + } + + let costRateName = " "; + let costItemRateId; + if (costItem.CostRate.id !== null) { + costItemRateId = costItem.CostRate.id; + const costItemRateName = costItem.CostRate.Name; + const costItemRateIdentification = costItem.CostRate.Identification; + costRateName = costItemRateIdentification + " - " + costItemRateName; + } + + if (preferences.CostRate && !isScheduleOfRates) { + const costRateCell = CostUI.createTableCell(costRateName); + costRateCell.classList.add("rate-cell", "clickable-cell"); + costRateCell.setAttribute("data-column", "Linked Rate"); + row.appendChild(costRateCell); + } + + if (preferences.Actions) { + const actionsCell = CostUI.costItemActions( + costItem, + isScheduleOfRates, + callbacks + ); + actionsCell.setAttribute("data-column", "Actions"); + actionsCell.classList.add("actions-column"); + row.appendChild(actionsCell); + } + + row.get_id = function () { + return this.getAttribute("id"); + }; + + row.isRate = isScheduleOfRates; + row.nestingLevel = nestingLevel; + row.get_parent = function () { + const parentId = this.getAttribute("parent-id"); + return parentId ? document.getElementById(parentId) : null; + }; + return row; + } + + static addResizer(resizer) { + let startX, startWidth, th; + + resizer.addEventListener("mousedown", function (e) { + th = e.target.parentElement; + startX = e.pageX; + startWidth = th.offsetWidth; + document.addEventListener("mousemove", resizeColumn); + document.addEventListener("mouseup", stopResize); + }); + + function resizeColumn(e) { + const newWidth = startWidth + (e.pageX - startX); + th.style.width = newWidth + "px"; + } + + function stopResize() { + document.removeEventListener("mousemove", resizeColumn); + document.removeEventListener("mouseup", stopResize); + } + } + + static format_number(number) { + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + useGrouping: true, + }) + .format(number) + .replace(/,/g, " "); + } + + static deleteCostItem(costItemId) { + const costItemRow = CostUI.getCostItemRow(costItemId); + let expandedState = JSON.parse(localStorage.getItem("expandedState")) || {}; + expandedState = CostUI.deleteCostItemRow(costItemRow, expandedState); + localStorage.setItem("expandedState", JSON.stringify(expandedState)); + } + + static createCostItemRow(costItem, nestingLevel, parentID) { + const row = document.createElement("tr"); + row.setAttribute("id", costItem.id); + row.setAttribute("parent-id", parentID); + if (nestingLevel > 0) { + row.classList.add(`level-${nestingLevel}`); + } + return row; + } + + static deleteCostItemRow(targetRow, expandedState) { + if (!targetRow) { + return; + } + delete expandedState[targetRow.id]; + targetRow.remove(); + const subRows = document.querySelectorAll(`[parent-id='${targetRow.id}']`); + subRows.forEach((subRow) => { + CostUI.deleteCostItemRow(subRow, expandedState); + }); + return expandedState; + } + + static createExpandButton(row) { + const expandButton = document.createElement("i"); + expandButton.classList.add("action-button"); + expandButton.classList.add("fa-solid"); + expandButton.classList.add("fa-angle-right"); + + if (row.IsNestedBy && row.IsNestedBy.length > 0) { + expandButton.classList.add("fa-angle-right"); + } else { + expandButton.style.visibility = "hidden"; + } + + expandButton.addEventListener("click", function (event) { + event.stopPropagation(); + let isExpanded = CostUI.isRowExpanded(row.id); + if (event.shiftKey) { + if (isExpanded) { + CostUI.contractRow(row.id, true); + CostUI.updateExpandedState(row.id, false, true); + } else { + CostUI.expandRow(row.id, true); + CostUI.updateExpandedState(row.id, true, true); + } + } else { + if (isExpanded) { + CostUI.contractRow(row.id, true); + CostUI.updateExpandedState(row.id, false); + } else { + CostUI.expandRow(row.id); + CostUI.updateExpandedState(row.id, true); + } + } + }); + + return expandButton; + } + + static hideRow(id) { + const row = document.getElementById(id); + if (row) { + row.classList.add("nested"); + } + } + + static contractRow(parentId, recursive = false) { + CostUI.updateToggleButton(parentId, "contract"); + const rows = document.querySelectorAll(`[parent-id='${parentId}']`); + rows.forEach((row) => { + const childId = row.getAttribute("id"); + CostUI.hideRow(childId); + if (recursive || (childId && CostUI.isRowExpanded(childId))) { + CostUI.contractRow(childId, recursive); + } + }); + } + + static showRow(id) { + const row = document.getElementById(id); + if (row) { + row.classList.remove("nested"); + } + } + + static updateToggleButton(id, type = "contract") { + const toggleButton = document.querySelector(`[id='${id}'] .action-button`); + if (!toggleButton) { + return; + } + if (type === "expand") { + toggleButton.classList.remove("fa-angle-right"); + toggleButton.classList.add("fa-angle-up"); + } else { + toggleButton.classList.remove("fa-angle-up"); + toggleButton.classList.add("fa-angle-right"); + } + } + + static expandRow(parentId, recursive = false) { + CostUI.updateExpandedState(parentId, true); + CostUI.updateToggleButton(parentId, "expand"); + const rows = document.querySelectorAll(`[parent-id='${parentId}']`); + rows.forEach((row) => { + const childId = row.getAttribute("id"); + CostUI.showRow(childId); + if (recursive || (childId && CostUI.isRowExpanded(childId))) { + CostUI.expandRow(childId, recursive); + } + }); + } + + static updateExpandedState(id, isExpanded, recursive = false) { + const expandedState = + JSON.parse(localStorage.getItem("expandedState")) || {}; + expandedState[id] = isExpanded; + localStorage.setItem("expandedState", JSON.stringify(expandedState)); + if (recursive) { + const rows = document.querySelectorAll(`[parent-id='${id}']`); + rows.forEach((row) => { + CostUI.updateExpandedState(row.id, isExpanded, recursive); + }); + } + } + + static applyExpandedState() { + const expandedState = + JSON.parse(localStorage.getItem("expandedState")) || + CostUI.getAllCostItemsIds().reduce((acc, id) => { + acc[id] = true; + return acc; + }, {}); + + Object.keys(expandedState).forEach((id) => { + if (expandedState[id]) { + CostUI.expandRow(id); + } else { + CostUI.contractRow(id); + } + }); + } + + static isRowExpanded(id) { + const expandedState = + JSON.parse(localStorage.getItem("expandedState")) || {}; + return expandedState[id] || false; + } + + static createNameCell(costItem, nestingLevel, expandButton, callbacks) { + const nameCell = document.createElement("td"); + const nameInput = document.createElement("input"); + nameInput.value = costItem.Name ? costItem.Name : "Unnamed"; + + nameInput.addEventListener("change", function () { + callbacks.editCostItemName + ? callbacks.editCostItemName(costItem.id, this.value) + : null; + }); + + nameInput.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + callbacks.editCostItemName + ? callbacks.editCostItemName(costItem.id, this.value) + : null; + } + }); + + nameCell.style.paddingLeft = nestingLevel * 20 + "px"; + nameCell.classList.add("row-container"); + nameCell.appendChild(expandButton); + nameCell.appendChild(nameInput); + const widthButton = 20; + nameInput.style.width = "calc(100% - " + widthButton + "%)"; + return nameCell; + } + + static createTableCell(content) { + const cell = document.createElement("td"); + cell.textContent = content; + return cell; + } + + static createCostCell(appliedValue) { + return CostUI.createTableCell(appliedValue); + } + + static createTotalCostCell(costItem, isScheduleOfRates, callback) { + const totalCostCell = document.createElement("td"); + const totalCost = CostUI.format_number(costItem.TotalCost); + totalCostCell.textContent = totalCost; + if (isScheduleOfRates) { + return totalCostCell; + } + if (costItem.IsSum) { + totalCostCell.textContent = totalCost + " *"; + } else if (!costItem.TotalCost) { + const button = CostUI.createButton("Sum", "fa-solid fa-calculator"); + button.addEventListener("click", function () { + const costItemId = parseInt(this.closest("tr").getAttribute("id")); + callback(costItemId); + }); + const onHoverElement = CostUI.onHoverElement(); + onHoverElement.appendChild(button); + totalCostCell.appendChild(onHoverElement); + } + + return totalCostCell; + } + + static onHoverElement() { + const div = document.createElement("div"); + div.classList.add("actions-column"); + div.classList.add("append-right"); + return div; + } + + static getSelectedCostItems() { + const selectedCostItems = document.selectedCostItems || []; + return selectedCostItems.map((item) => parseInt(item, 10)); + } + + static costItemActions(costItem, isScheduleOfRates, callbacks) { + const divFlex = document.createElement("div"); + divFlex.classList.add("row-container"); + const addCostItem = CostUI.addCostItemButton( + costItem.id, + callbacks.addCostItem + ); + const selectButton = CostUI.createSelectButton( + costItem.id, + callbacks.selectAssignedElements + ); + const deleteButton = CostUI.deleteCostItemButton( + costItem.id, + callbacks.deleteCostItem + ); + const duplicateButton = CostUI.duplicateCostItemButton( + costItem.id, + callbacks.duplicateCostItem + ); + const assignCostItemButton = CostUI.createButton( + "Assign", + "fa-solid fa-link" + ); + assignCostItemButton.addEventListener("click", function () { + callbacks.assignCostValues(costItem.id); + }); + + let buttons = [addCostItem, duplicateButton, selectButton, deleteButton]; + if (isScheduleOfRates) { + buttons.push(assignCostItemButton); + } + + buttons.forEach((button) => { + divFlex.appendChild(button); + }); + + const actionsCell = document.createElement("td"); + actionsCell.appendChild(divFlex); + return actionsCell; + } + + static duplicateCostItemButton(costItemId, duplicateCostItem) { + const duplicateButton = CostUI.createButton( + "Duplicate", + "fa-solid fa-copy" + ); + duplicateButton.addEventListener( + "click", + duplicateCostItem.bind(null, costItemId) + ); + return duplicateButton; + } + + static addCostItemButton(costItemId, addCostItem) { + const addCostItemButton = CostUI.createButton( + "Add Sub-Cost", + "fa-solid fa-plus" + ); + addCostItemButton.addEventListener( + "click", + addCostItem.bind(null, costItemId) + ); + return addCostItemButton; + } + + static createSelectButton(costItemId, selectAssignedElements) { + const selectButton = CostUI.createButton( + "Select", + "fa-solid fa-arrow-pointer" + ); + selectButton.addEventListener( + "click", + selectAssignedElements.bind(null, costItemId) + ); + return selectButton; + } + + static deleteCostItemButton(costItemId, deleteCostItem) { + const deleteButton = CostUI.createButton("Delete", "fa-solid fa-trash"); + deleteButton.addEventListener( + "click", + deleteCostItem.bind(null, costItemId) + ); + return deleteButton; + } + + static createButton(text, icon) { + const button = document.createElement("button"); + !icon ? (button.textContent = text) : null; + icon + ? button.classList.add("action-button", ...icon.split(" ")) + : button.classList.add("action-button"); + button.dataset.tooltip = text; + return button; + } + + static highlightElement(id) { + const element = document.getElementById(id); + if (element) { + element.classList.add("highlighted"); + } + } + + static unhighlightElement(id) { + const element = document.getElementById(id); + if (element) { + element.classList.remove("highlighted"); + } + } + + static getAllCostItemsIds() { + const rows = document.querySelectorAll("#cost-items tr"); + const ids = []; + rows.forEach((row) => { + ids.push(row.id); + }); + return ids; + } + + static Text(label, icon = null, size = "medium") { + const text = document.createElement("span"); + text.textContent = label; + if (icon === null) { + return text; + } + const div = document.createElement("div"); + div.classList.add("row-container"); + const i = document.createElement("i"); + i.className = icon; + div.appendChild(i); + div.appendChild(text); + div.style.fontSize = size; + return div; + } + + static Error(container, message) { + const existingError = container.querySelector(".error"); + existingError ? existingError.remove() : null; + + const error = this.Text( + message, + "fa-solid fa-exclamation-triangle", + "large" + ); + error.classList.add("error", "message"); + + const closeButton = this.createCloseButton(() => { + error.remove(); + }); + + error.appendChild(closeButton); + container.appendChild(error); + return error; + } + + static Success(container, message) { + const existingSuccess = container.querySelector(".success"); + existingSuccess ? existingSuccess.remove() : null; + + const success = this.Text(message, "fa-solid fa-check-circle", "large"); + success.classList.add("success", "message"); + + const closeButton = this.createCloseButton(() => { + success.remove(); + }); + + success.appendChild(closeButton); + container.appendChild(success); + return success; + } + + static createCard(id, title, cardBody, callback) { + const card = document.createElement("div"); + card.classList.add("card"); + card.id = "schedule-" + id; + + const cardContainer = document.createElement("div"); + cardContainer.classList.add("card-body"); + cardBody.classList.add("card-body"); + + const cardTitle = CostUI.Text( + title, + "fa-solid fa-money-bill-wave", + "large" + ); + cardTitle.classList.add("card-header"); + + const cardButton = CostUI.createButton("Load", "fa-solid fa-arrows-rotate"); + cardButton.addEventListener("click", callback); + cardTitle.appendChild(cardButton); + + card.appendChild(cardTitle); + card.appendChild(cardContainer); + + cardContainer.appendChild(cardBody); + + return card; + } + + static getCostItemRow(costItemId) { + return document.getElementById(costItemId); + } + + static getRowNameCell(costItemId) { + return CostUI.getCostItemRow(costItemId) + ? CostUI.getCostItemRow(costItemId).querySelector("td input") + : null; + } + + static getCostItemName(costItemId) { + return CostUI.getRowNameCell(costItemId) + ? CostUI.getRowNameCell(costItemId).value + : "Unnamed"; + } + + static createCostValuesForm({ costItemId, costValues, callbacks }) { + const formId = "cost-values-form-" + costItemId; + + const costItemRow = CostUI.getCostItemRow(costItemId); + const isRate = costItemRow.isRate; + + let existingForm = document.getElementById(formId); + if (existingForm) { + existingForm.remove(); + } + const costItemName = CostUI.getCostItemName(costItemId); + + let formName = "Cost Values: " + costItemName; + const form = CostUI.Form({ + icon: "fa-dollar-sign", + id: formId, + name: formName, + }); + let headers; + if (isRate) { + headers = [ + "Type", + "Category", + "Value", + "Unit Symbol", + "Unit Component", + "", + ]; + } else { + headers = ["Type", "Category", "Value", ""]; + } + + const { tableContainer, table } = CostUI.addTable({ + headers: headers, + className: "cost-values-table", + id: "cost-values-table-" + costItemId, + }); + + costValues.forEach((costValue) => { + costValue.isRate = isRate; + const tr = CostUI.createCostvaluesRow({ + costItemId, + costValue, + costValueCallbacks: callbacks, + }); + table.appendChild(tr); + }); + form.appendChild(tableContainer); + if (!isRate) { + //TODO: IMPLEMENT ADDING COST VALUES FOR COST RATES + const addButton = CostUI.createAddCostValueButton(costItemId, callbacks); + form.appendChild(addButton); + } + return form; + } + + static getCostValuesTable(costItemId) { + return document.getElementById("cost-values-table-" + costItemId); + } + + static addNewCostValueRow(costItemId, costValueId, costValueCallbacks) { + const table = CostUI.getCostValuesTable(costItemId); + if (!table) { + return; + } + const costItemRow = CostUI.getCostItemRow(costItemId); + const isRate = costItemRow.isRate; + let newData; + if (isRate) { + newData = { + category: "", + name: "", + applied_value: 0, + id: costValueId, + parent: costItemId, + unit_data: { + unit_symbol: "", + value_component: 0, + }, + }; + } else { + newData = { + category: "", + name: "", + applied_value: 0, + id: costValueId, + parent: costItemId, + }; + } + const tr = CostUI.createCostvaluesRow({ + costItemId, + costValue: newData, + costValueCallbacks, + }); + table.appendChild(tr); + } + + static addTable({ id, className, headers }) { + const div = document.createElement("div"); + div.classList.add("table-container"); + const table = document.createElement("table"); + div.appendChild(table); + if (className !== "") { + table.classList.add(className); + } + table.id = id; + + const headerRow = document.createElement("tr"); + headers.forEach((headerText) => { + const th = document.createElement("th"); + th.style.position = "relative"; + th.textContent = headerText; + headerRow.appendChild(th); + }); + const thead = document.createElement("thead"); + thead.appendChild(headerRow); + table.appendChild(thead); + table.get_header_row = function () { + return headerRow; + }; + table.get_header_by_name = function (name) { + const headers = headerRow.querySelectorAll("th"); + return Array.from(headers).find((header) => header.textContent === name); + }; + return { tableContainer: div, table: table }; + } + + static createCostvaluesRow({ costItemId, costValue, costValueCallbacks }) { + if (document.getElementById(costValue.id)) { + return document.getElementById(costValue.id); + } + + const tr = document.createElement("tr"); + tr.parent = costItemId; + tr.id = costValue.id; + + const costType = CostUI.determineCostType(costValue); + const { dropdown, typeCell } = CostUI.createTypeCell( + tr, + costType, + costItemId, + costValue, + costValueCallbacks + ); + tr.appendChild(typeCell); + + const categoryCell = CostUI.createTableInput( + "text", + "category", + costValue.category + ); + tr.appendChild(categoryCell); + + const value = CostUI.determineValue(costValue); + const valueCell1 = CostUI.createTableInput("number", "value", value); + tr.appendChild(valueCell1); + let unitSymbolCell; + let unitComponentCell; + if (costValue.isRate) { + unitSymbolCell = CostUI.Text(costValue.unit_data.unit_symbol); //TODO CostUI.createTableInput("text", "unit_symbol", costValue.unit_data.unit_symbol); + tr.appendChild(unitSymbolCell); + unitComponentCell = CostUI.createTableInput( + "number", + "unit_value_component", + costValue.unit_data.value_component + ); + tr.appendChild(unitComponentCell); + } + + CostUI.addValueChangeListeners( + tr, + costItemId, + costValue, + costValueCallbacks + ); + dropdown.addEventListener("change", function () { + const selectedType = this.value; + CostUI.updateRowBasedOnType(selectedType, categoryCell, valueCell1); + + if (selectedType === "SUM") { + const costValueId = parseInt(this.closest("tr").getAttribute("id")); + const appliedValue = parseFloat( + valueCell1.querySelector("input").value + ); + const costCategory = categoryCell.querySelector("input").value; + + if (costValueCallbacks.editCostValues) { + costValueCallbacks.editCostValues(costItemId, [ + { + costType: selectedType, + costCategory: costCategory, + appliedValue: appliedValue, + id: costValueId, + costItemId: costItemId, + }, + ]); + } + } + }); + + const deleteCell = CostUI.createDeleteCell( + costItemId, + costValue, + costValueCallbacks, + tr + ); + tr.appendChild(deleteCell); + + CostUI.updateRowBasedOnType(costType, categoryCell, valueCell1); + return tr; + } + + static determineCostType(costValue) { + if (costValue.category === "*") { + return "SUM"; + } else if (costValue.category && costValue.category !== "*") { + return "CATEGORY"; + } else if (costValue.applied_value) { + return "FIXED"; + } + return "FIXED"; + } + + static createTypeCell(costType, costItemId, costValue, costValueCallbacks) { + const options = ["FIXED", "CATEGORY", "SUM"]; + const typeCell = document.createElement("td"); + const dropdown = CostUI.createTableDropdown({ + name: "type", + options: options, + defaultValue: costType, + }); + typeCell.appendChild(dropdown); + + return { dropdown, typeCell }; + } + + static determineValue(costValue) { + function cleanLabel(value) { + return value.replace(/[^0-9.]/g, ""); + } + + if (costValue.category === "*") { + return cleanLabel(costValue.label); + } else { + return costValue.applied_value; + } + } + + static getCostValueData({ + typeCell, + categoryCell, + valueCell1, + componentCell, + }) { + const costType = typeCell.value; + const costCategory = categoryCell ? categoryCell.value : null; + const appliedValue = parseFloat(valueCell1.value); + const unitBasisValue = componentCell + ? parseFloat(componentCell.value) + : null; + return { costType, costCategory, appliedValue, unitBasisValue }; + } + + static addValueChangeListeners( + tr, + costItemId, + costValue, + costValueCallbacks + ) { + function handleCostValueChange(costItemId) { + let costValueData = CostUI.getCostValueData({ + typeCell, + categoryCell, + valueCell1, + componentCell, + }); + costValueData = { + ...costValueData, + id: costValue.id, + costItemId: costItemId, + }; + if (costValueData.unitBasisValue) { + costValueData.unitComponent = costValue.unit_data.unit_component; + } + if (costValueCallbacks.editCostValues) { + costValueCallbacks.editCostValues(costItemId, [costValueData]); + } + } + + const typeCell = tr.querySelector("td select[name='type']"); + const categoryCell = tr.querySelector("td input[name='category']"); + const valueCell1 = tr.querySelector("td input[name='value']"); + const componentCell = tr.querySelector( + "td input[name='unit_value_component']" + ); + valueCell1.addEventListener( + "change", + handleCostValueChange.bind(valueCell1, costItemId) + ); + + valueCell1.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + handleCostValueChange.call(this, costItemId); + } + }); + + if (componentCell) { + componentCell.addEventListener( + "change", + handleCostValueChange.bind(componentCell, costItemId) + ); + componentCell.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + handleCostValueChange.call(this, costItemId); + } + }); + } + } + + static createDeleteCell(costItemId, costValue, costValueCallbacks, tr) { + const deleteCell = document.createElement("td"); + const deleteButton = CostUI.createButton("Delete", "fa-solid fa-trash"); + + deleteButton.addEventListener("click", function () { + costValueCallbacks.deleteCostValue(costItemId, costValue.id); + tr.remove(); + }); + + deleteCell.appendChild(deleteButton); + return deleteCell; + } + + static updateRowBasedOnType(type, categoryCell, valueCell1) { + if (type === "FIXED") { + categoryCell.querySelector("input").disabled = true; + categoryCell.querySelector("input").style.backgroundColor = "lightgrey"; + valueCell1.querySelector("input").disabled = false; + valueCell1.querySelector("input").style.backgroundColor = ""; + } else if (type === "CATEGORY") { + categoryCell.querySelector("input").disabled = false; + categoryCell.querySelector("input").style.backgroundColor = ""; + valueCell1.querySelector("input").disabled = false; + valueCell1.querySelector("input").style.backgroundColor = ""; + } else if (type === "SUM") { + categoryCell.querySelector("input").disabled = true; + categoryCell.querySelector("input").style.backgroundColor = "lightgrey"; + valueCell1.querySelector("input").disabled = true; + valueCell1.querySelector("input").style.backgroundColor = "lightgrey"; + } + } + + static createTableDropdown({ name, options, defaultValue = "FIXED" }) { + const dropdown = document.createElement("select"); + dropdown.name = name; + options.forEach((optionValue) => { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = optionValue; + if (optionValue === defaultValue) { + option.selected = true; + } + dropdown.appendChild(option); + }); + return dropdown; + } + + static createTableInput(type, name, value) { + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = type; + input.name = name; + input.value = value; + cell.appendChild(input); + return cell; + } + + static createAddCostValueButton(costItemId, callbacks) { + const addButton = CostUI.createButton("Add Cost Value", "fa-solid fa-plus"); + addButton.addEventListener("click", function (e) { + e.preventDefault(); + callbacks.addCostValue ? callbacks.addCostValue(costItemId) : null; + }); + return addButton; + } + + static Form({ id, name, icon = "fa-solid fa-file", shouldHide = false }) { + function style(form) { + form.classList.add("floating-form"); + form.style.position = "absolute"; + form.style.top = "50%"; + form.style.left = "50%"; + form.style.transform = "translate(-50%, -50%)"; + } + if (document.getElementById(id)) { + const formContainer = document + .getElementById(id) + .querySelector(".form-container"); + formContainer.innerHTML = ""; + return formContainer; + } + const form = document.createElement("form"); + form.id = id; + style(form); + const header = CostUI.createHeader(name, icon); + let closeButton; + + if (shouldHide) { + closeButton = CostUI.createHideButton(form); + } else { + const callback = () => { + form.remove(); + const costItemId = form.id.split("-").pop(); + CostUI.unhighlightElement(costItemId); + }; + closeButton = CostUI.createCloseButton(callback); + } + form.appendChild(header); + form.appendChild(closeButton); + CostUI.makeFormDraggable(form); + + const formContainer = document.createElement("div"); + formContainer.classList.add("form-container"); + form.appendChild(formContainer); + document.body.appendChild(form); + return formContainer; + } + static makeFormDraggable(form) { + let pos1 = 0, + pos2 = 0, + pos3 = 0, + pos4 = 0; + form.onmousedown = function (e) { + if (!e.target.closest("input, select, button")) { + dragMouseDown(e); + } + }; + + const formElements = form.querySelectorAll("input, select, button"); + formElements.forEach((element) => { + element.onmousedown = function (e) { + e.stopImmediatePropagation(); + }; + }); + + function dragMouseDown(e) { + e = e || window.event; + e.preventDefault(); + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + document.onmousemove = elementDrag; + } + + function elementDrag(e) { + e = e || window.event; + e.preventDefault(); + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + + let newTop = form.offsetTop - pos2; + let newLeft = form.offsetLeft - pos1; + + const minTop = form.offsetHeight / 2; + const minLeft = form.offsetWidth / 2; + const maxTop = window.innerHeight - form.offsetHeight / 2; + const maxLeft = window.innerWidth - form.offsetWidth / 2; + + if (newTop < minTop) newTop = minTop; + if (newTop > maxTop) newTop = maxTop; + if (newLeft < minLeft) newLeft = minLeft; + if (newLeft > maxLeft) newLeft = maxLeft; + + form.style.top = newTop + "px"; + form.style.left = newLeft + "px"; + } + + function closeDragElement() { + document.onmouseup = null; + document.onmousemove = null; + } + } + + static createHeader(text, icon) { + const header = document.createElement("div"); + const i = document.createElement("i"); + i.className = icon; + header.classList.add("form-header"); + const span = document.createElement("span"); + span.textContent = text; + header.appendChild(i); + header.appendChild(span); + return header; + } + + static createHideButton(form) { + const hideButton = document.createElement("span"); + hideButton.classList.add("close-button"); + hideButton.innerHTML = "−"; + hideButton.addEventListener("click", function () { + form.style.display = "none"; + }); + return hideButton; + } + + static createCloseButton(callback) { + const closeButton = document.createElement("span"); + closeButton.classList.add("close-button"); + closeButton.innerHTML = "×"; + closeButton.addEventListener("click", callback); + return closeButton; + } + + static createProductRow(product, shouldAddQto = false) { + const row = document.createElement("tr"); + + const cellData = { + id: product.info.id, + class: product.info.class, + name: product.info.name, + type: product.info.type, + count: "1", + }; + + for (const [key, value] of Object.entries(cellData)) { + const cell = document.createElement("td"); + cell.textContent = value; + row.appendChild(cell); + } + + if (shouldAddQto) { + const quantitySets = product.qtos; + Object.entries(quantitySets).forEach(([qtoName, qto]) => { + Object.entries(qto).forEach(([key, value]) => { + if (key !== "id") { + const cell = document.createElement("td"); + cell.textContent = parseFloat(value).toFixed(2); + row.appendChild(cell); + } + }); + }); + } + + return row; + } + + static addQuantityHeaders(table, products, shouldAddQto = true) { + const headerRow = table.get_header_row(); + const countHeader = table.get_header_by_name("Count"); + const quantitySums = { count: 0 }; + const quantityHeaders = { count: countHeader }; + if (!shouldAddQto) return { quantitySums, quantityHeaders }; + if (products.length > 0) { + const quantitySets = products[0].qtos; + Object.entries(quantitySets).forEach(([qtoName, qto]) => { + Object.keys(qto).forEach((key) => { + if (key !== "id") { + const qtoHeader = document.createElement("th"); + qtoHeader.textContent = key; + qtoHeader.style.cursor = "pointer"; + headerRow.appendChild(qtoHeader); + quantitySums[key] = 0; + quantityHeaders[key] = qtoHeader; + } + }); + }); + } + + return { quantitySums, quantityHeaders }; + } + + static getAssinedQuantity(products, costItemId) { + let quantityAssigned = null; + products.forEach((product) => { + const product_id = product.info.id; + const assignedQuantities = product.assigned_quantities; + if (assignedQuantities.length > 0) { + assignedQuantities.forEach((assignedQuantity) => { + if (assignedQuantity.cost_item_id === costItemId) { + product.isProductAssigned = true; + quantityAssigned = assignedQuantity.name; + } + }); + } + }); + return quantityAssigned; + } + + static calculateSums(qtos, quantitySums) { + Object.entries(qtos).forEach(([qtoName, qto]) => { + Object.entries(qto).forEach(([key, value]) => { + if (key !== "id") { + quantitySums[key] += parseFloat(value); + } + }); + }); + } + + static countProducts(products, quantitySums) { + products.forEach((product) => { + quantitySums.count += 1; + }); + } + + static addSubTotalRow(quantitySums, shouldAddQto = true) { + const subtotalRow = document.createElement("tr"); + + const cells = [ + { textContent: "", style: { border: "none" } }, + { textContent: "", style: { border: "none" } }, + { textContent: "", style: { border: "none" } }, + { textContent: "Subtotal", colSpan: 1, style: { border: "none" } }, + { textContent: quantitySums.count }, + ]; + + cells.forEach((cellData) => { + const cell = document.createElement("td"); + if (cellData.textContent !== undefined) + cell.textContent = cellData.textContent; + if (cellData.colSpan !== undefined) cell.colSpan = cellData.colSpan; + if (cellData.style !== undefined) + Object.assign(cell.style, cellData.style); + subtotalRow.appendChild(cell); + }); + + if (shouldAddQto) { + Object.keys(quantitySums).forEach((key) => { + if (key !== "count") { + const subtotalCell = document.createElement("td"); + subtotalCell.textContent = quantitySums[key].toFixed(2); + subtotalRow.appendChild(subtotalCell); + } + }); + } + + subtotalRow.classList.add("subtotal-row"); + + return subtotalRow; + } + + static createProductTable({ + container, + products, + quantityNames, + costItemId, + callbacks, + }) { + if (products.length === 0) { + const noProductsMessage = document.createElement("p"); + noProductsMessage.textContent = + "Your Blender Selection is empty! Select objects first."; + container.appendChild(noProductsMessage); + return; + } + const { tableContainer, table } = CostUI.addTable({ + headers: ["Step Id", "Class", "Name", "Type", "Count"], + className: "", + id: "cost-values-table-" + costItemId, + }); + container.appendChild(tableContainer); + const tbody = document.createElement("tbody"); + table.appendChild(tbody); + + const quantityAssigned = CostUI.getAssinedQuantity(products, costItemId); + const areProductsSameClass = products.every( + (product) => product.info.class === products[0].info.class + ); + + const shouldAddQto = areProductsSameClass ? true : false; + const headerRow = table.get_header_row(); + const { quantitySums, quantityHeaders } = CostUI.addQuantityHeaders( + table, + products, + shouldAddQto + ); + + if (shouldAddQto) { + CostUI.highlightColumn( + table, + headerRow, + quantityHeaders, + quantityAssigned + ); + + products.forEach((product) => { + const row = CostUI.createProductRow(product, shouldAddQto); + tbody.appendChild(row); + + const isProductAssigned = product.assigned_quantities + ? product.assigned_quantities.some( + (assignedQuantity) => assignedQuantity.cost_item_id === costItemId + ) + : false; + isProductAssigned ? row.classList.add("highlighted") : null; + + CostUI.calculateSums(product.qtos, quantitySums); + }); + } else { + products.forEach((product) => { + const row = CostUI.createProductRow(product, shouldAddQto); + tbody.appendChild(row); + }); + } + + CostUI.countProducts(products, quantitySums); + + const subtotalRow = CostUI.addSubTotalRow(quantitySums, shouldAddQto); + tbody.appendChild(subtotalRow); + + const quantitySelect = document.createElement("select"); + + if (shouldAddQto) { + quantityNames.forEach((name) => { + const option = document.createElement("option"); + option.value = name; + option.text = name; + quantitySelect.appendChild(option); + }); + } + + const option = document.createElement("option"); + option.value = "count"; + option.text = "count"; + quantitySelect.appendChild(option); + + quantitySelect.addEventListener("change", (event) => { + const selectedQuantity = event.target.value; + + Object.values(quantityHeaders).forEach((header) => { + header.classList.remove("highlighted"); + }); + table.querySelectorAll("td").forEach((cell) => { + cell.classList.remove("highlighted"); + }); + + if (quantityHeaders[selectedQuantity]) { + quantityHeaders[selectedQuantity].classList.add("highlighted"); + const columnIndex = Array.from(headerRow.children).indexOf( + quantityHeaders[selectedQuantity] + ); + table.querySelectorAll(`tr`).forEach((row) => { + row.children[columnIndex].classList.add("highlighted"); + }); + } + }); + + Object.entries(quantityHeaders).forEach(([key, header]) => { + header.addEventListener("click", () => { + quantitySelect.value = key; + const event = new Event("change"); + quantitySelect.dispatchEvent(event); + }); + }); + + quantityAssigned + ? CostUI.highlightColumn( + table, + headerRow, + quantityHeaders, + quantityAssigned + ) + : null; + + quantitySelect.value = quantityAssigned ? quantityAssigned : "count"; + + const productIds = products.map((product) => product.info.id); + callbacks.emptyForm = () => { + container.innerHTML = ""; + }; + const addProductAssignmentsButton = CostUI.addProductAssignmentsButton( + costItemId, + productIds, + quantitySelect, + callbacks + ); + + container.appendChild(quantitySelect); + container.appendChild(addProductAssignmentsButton); + return table; + } + + static createAssignmentsTable({ + container, + products, + costItemId, + quantityNames, + callbacks, + }) { + const { tableContainer, table } = CostUI.addTable({ + headers: ["Step Id", "Class", "Name", "Type", "Count"], + className: "", + id: "cost-values-table-" + costItemId, + }); + + container.appendChild(tableContainer); + const tbody = document.createElement("tbody"); + table.appendChild(tbody); + + let quantityAssigned = CostUI.getAssinedQuantity(products, costItemId); + const shouldAddQto = quantityAssigned ? true : false; + const headerRow = table.get_header_row(); + const { quantitySums, quantityHeaders } = CostUI.addQuantityHeaders( + table, + products, + shouldAddQto + ); + + shouldAddQto + ? CostUI.highlightColumn( + table, + headerRow, + quantityHeaders, + quantityAssigned + ) + : null; + + products.forEach((product) => { + const row = CostUI.createProductRow(product, shouldAddQto); + tbody.appendChild(row); + shouldAddQto ? CostUI.calculateSums(product.qtos, quantitySums) : null; + }); + + CostUI.countProducts(products, quantitySums); + + const subtotalRow = CostUI.addSubTotalRow(quantitySums, shouldAddQto); + tbody.appendChild(subtotalRow); + } + + static highlightColumn(table, headerRow, quantityHeaders, quantityAssigned) { + if (quantityAssigned) { + if (quantityHeaders[quantityAssigned]) { + quantityHeaders[quantityAssigned].classList.add("highlighted"); + const columnIndex = Array.from(headerRow.children).indexOf( + quantityHeaders[quantityAssigned] + ); + table.querySelectorAll(`tr`).forEach((row) => { + row.children[columnIndex].classList.add("highlighted"); + }); + } + } + } + static addProductAssignmentsButton( + costItemId, + productIds, + quantitySelect, + callbacks + ) { + const addProductAssignmentsButton = CostUI.createButton( + "Add Product Assignments", + "fa-solid fa-plus" + ); + addProductAssignmentsButton.addEventListener("click", (event) => { + event.preventDefault(); + let propName = quantitySelect.value; + if (propName === "count") { + propName = ""; + } + callbacks.addProductAssignments + ? callbacks.addProductAssignments({ + costItemId, + selectedQuantity: propName, + productIds, + }) + : null; + callbacks.emptyForm ? callbacks.emptyForm() : null; + callbacks.enableEditingQuantities(costItemId); + }); + addProductAssignmentsButton.classList.add("action-button"); + return addProductAssignmentsButton; + } + + static createFontSizePicker() { + const div = document.createElement("div"); + const fontSizeText = document.createElement("p"); + fontSizeText.textContent = "Select a font size for the table:"; + div.appendChild(fontSizeText); + const fontSizePicker = document.createElement("input"); + fontSizePicker.type = "number"; + fontSizePicker.id = "font-size-picker"; + fontSizePicker.value = localStorage.getItem("tableFontSize") || 14; + div.appendChild(fontSizePicker); + fontSizePicker.addEventListener("input", function () { + const fontSize = fontSizePicker.value + "px"; + CostUI.saveFontSize(fontSize); + CostUI.applyFontSizeToAllTables(fontSize); + }); + return div; + } + + static createColorPicker() { + const div = document.createElement("div"); + const colorText = document.createElement("p"); + colorText.textContent = "Select a color for the table:"; + div.appendChild(colorText); + const colorPicker = document.createElement("input"); + colorPicker.type = "color"; + colorPicker.id = "color-picker"; + colorPicker.value = localStorage.getItem("tableColor") || "#ffffff"; + div.appendChild(colorPicker); + colorPicker.addEventListener("input", function () { + const color = colorPicker.value; + CostUI.saveColor(color); + CostUI.applyColorToAllTables(color); + }); + return div; + } + + static createCurrencyPicker() { + const currency = CostUI.getCurrency(); + const div = document.createElement("div"); + const currencyText = document.createElement("p"); + currencyText.textContent = "Select a currency for the table:"; + div.appendChild(currencyText); + const currencyPicker = document.createElement("input"); + currencyPicker.type = "text"; + currencyPicker.id = "currency-picker"; + currencyPicker.value = currency; + div.appendChild(currencyPicker); + currencyPicker.addEventListener("input", function () { + const currency = currencyPicker.value; + CostUI.saveCurrency(currency); + }); + return div; + } + + static saveCurrency(currency) { + localStorage.setItem("tableCurrency", currency); + } + + static getCurrency() { + return localStorage.getItem("tableCurrency"); + } + + static saveFontSize(fontSize) { + localStorage.setItem("tableFontSize", fontSize); + } + + static saveColor(color) { + localStorage.setItem("tableColor", color); + } + + static applyFontSizeToAllTables(fontSize) { + document.documentElement.style.setProperty("--fontSize", fontSize); + } + + static applyColorToAllTables(color) { + const tables = document.querySelectorAll("table"); + tables.forEach((table) => { + table.style.backgroundColor = color; + }); + } + + static applySavedSettings() { + const savedFontSize = localStorage.getItem("tableFontSize"); + if (savedFontSize) { + CostUI.applyFontSizeToAllTables(savedFontSize); + const fontSizePicker = document.getElementById("font-size-picker"); + if (fontSizePicker) { + fontSizePicker.value = parseInt(savedFontSize, 10); + } + } + + const savedColor = localStorage.getItem("tableColor"); + if (savedColor) { + CostUI.applyColorToAllTables(savedColor); + const colorPicker = document.getElementById("color-picker"); + if (colorPicker) { + colorPicker.value = savedColor; + } + } + } + + static getRibbonBar() { + return document.getElementById("ribbon-bar"); + } + + static addRibbonButton({ text, icon, callback }) { + const ribbonBar = CostUI.getRibbonBar(); + + const button = document.createElement("button"); + button.classList.add("action-button", ...icon.split(" ")); + button.dataset.tooltip = text; + + button.addEventListener("click", () => callback(button)); + + ribbonBar.appendChild(button); + } + + static toggleSchedulesContainer(button) { + const schedulesContainer = CostUI.getSchedulesContainer(); + const isHidden = schedulesContainer.style.display === "none"; + + if (isHidden) { + CostUI.displaySchedulesContainer(); + button.classList.remove("fa-eye"); + button.classList.add("fa-eye-slash"); + button.dataset.tooltip = "Hide Schedules"; + } else { + CostUI.hideSchedulesContainer(); + button.classList.remove("fa-eye-slash"); + button.classList.add("fa-eye"); + button.dataset.tooltip = "Display Schedules"; + } + } + + static getSchedulesContainer() { + return document.getElementById("cost-schedules"); + } + + static displaySchedulesContainer() { + const schedulesContainer = CostUI.getSchedulesContainer(); + schedulesContainer.style.display = "flex"; + } + + static hideSchedulesContainer() { + const schedulesContainer = CostUI.getSchedulesContainer(); + schedulesContainer.style.display = "none"; + } + + static getSettingsMenu() { + return document.getElementById("settings-menu"); + } + + static toggleSettingsMenu() { + const settingsMenu = CostUI.getSettingsMenu(); + if (settingsMenu.style.display === "none") { + settingsMenu.style.display = "block"; + } else { + settingsMenu.style.display = "none"; + } + } + + static createRibbonBar() { + const ribbonBar = document.createElement("div"); + ribbonBar.className = "switch-bar"; + const buttons = [ + { + text: "Parametric Take Off", + id: "selected-products", + }, + { + text: "Review Assignments", + id: "assigned-products", + }, + { + text: "Manual Take Off", + id: "manual-quantities", + }, + { + text: "Query Product qto", + id: "query-quantities", + }, + ]; + buttons.forEach((button) => { + const actionButton = CostUI.createButton(button.text); + actionButton.id = button.id + "-btn"; + ribbonBar.appendChild(actionButton); + }); + return ribbonBar; + } + + static enableEditingQuantities({ + costItemId, + selectedProducts, + assignedProducts, + quantityNames, + costQuantities, + callbacks, + }) { + CostUI.highlightElement(costItemId); + const form = CostUI.Form({ + id: "enable-editing-quantities-" + costItemId, + name: "Editing Quantities for: " + CostUI.getCostItemName(costItemId), + icon: "fa-solid fa-box", + }); + + const ribbonBar = CostUI.createRibbonBar(); + form.appendChild(ribbonBar); + + const selectedProductsSection = CostUI.selectedProductsSection({ + products: selectedProducts, + quantityNames, + costItemId, + callbacks, + }); + + const assignedProductsSection = CostUI.assignedProductsSection({ + products: assignedProducts, + quantityNames, + costItemId, + callbacks, + }); + + const manualQuantitiesSection = CostUI.manualQuantitiesSection({ + costItemId, + costQuantities, + has_assigned_products: assignedProducts.length > 0, + callbacks, + }); + + const QuantityQuerySection = CostUI.QuantityQuerySection({ + costItemId, + callbacks, + }); + + form.appendChild(selectedProductsSection); + form.appendChild(assignedProductsSection); + form.appendChild(manualQuantitiesSection); + form.appendChild(QuantityQuerySection); + + const summarySection = CostUI.createSummarySection({ + selectedProducts, + assignedProducts, + costQuantities, + }); + form.appendChild(summarySection); + + CostUI.addEventListeners({ + switchBar: ribbonBar, + costItemId, + selectedProductsSection, + assignedProductsSection, + manualQuantitiesSection, + QuantityQuerySection, + }); + + const lastActiveSection = + localStorage.getItem("lastActiveSection") || "selected-products"; + const lastActiveButton = document.getElementById( + `${lastActiveSection}-btn` + ); + if (lastActiveButton) { + lastActiveButton.click(); + } + } + + static createSummarySection({ + selectedProducts, + assignedProducts, + costQuantities, + }) { + const summarySection = document.createElement("div"); + summarySection.classList.add("summary-section"); + + const manualQuantities = costQuantities.quantities.filter( + (quantity) => !quantity.fromProduct + ); + const paramaterQuantities = costQuantities.quantities.filter( + (quantity) => quantity.fromProduct + ); + + const table = document.createElement("table"); + table.classList.add("summary-table"); + + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + const headers = ["Category", "Count"]; + headers.forEach((headerText) => { + const th = document.createElement("th"); + th.textContent = headerText; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + const tbody = document.createElement("tbody"); + + const data = [ + { category: "Assigned Products", count: assignedProducts.length }, + { category: "Manual Quantities", count: manualQuantities.length }, + { + category: "Product derived Quantities", + count: paramaterQuantities.length, + }, + ]; + + data.forEach((item) => { + const row = document.createElement("tr"); + const categoryCell = document.createElement("td"); + categoryCell.textContent = item.category; + const countCell = document.createElement("td"); + countCell.textContent = item.count; + row.appendChild(categoryCell); + row.appendChild(countCell); + tbody.appendChild(row); + }); + + table.appendChild(tbody); + summarySection.appendChild(table); + + return summarySection; + } + + static addEventListeners({ + switchBar, + costItemId, + selectedProductsSection, + assignedProductsSection, + manualQuantitiesSection, + QuantityQuerySection, + }) { + const buttons = switchBar.querySelectorAll(".action-button"); + + buttons.forEach((button) => { + button.addEventListener("click", (e) => { + e.preventDefault(); + const sectionName = button.id.replace("-btn", ""); + const sectionId = sectionName + "-section-" + costItemId; + + if (selectedProductsSection) + selectedProductsSection.style.display = "none"; + if (assignedProductsSection) + assignedProductsSection.style.display = "none"; + if (manualQuantitiesSection) + manualQuantitiesSection.style.display = "none"; + if (QuantityQuerySection) QuantityQuerySection.style.display = "none"; + + const section = document.getElementById(sectionId); + if (section) { + section.style.display = "block"; + } + buttons.forEach((btn) => btn.classList.remove("active-btn")); + button.classList.add("active-btn"); + + localStorage.setItem("lastActiveSection", sectionName); + }); + }); + } + + static getSection(costItemId, sectionName) { + return document.getElementById(`${sectionName}-${costItemId}`); + } + + static selectedProductsSection({ + products, + quantityNames, + costItemId, + callbacks, + }) { + const selectedProductsSection = document.createElement("div"); + selectedProductsSection.id = "selected-products-section-" + costItemId; + selectedProductsSection.classList.add("form-section"); + const numberOfProducts = CostUI.Text( + "Selection basket : " + products.length + " products", + "fa-solid fa-cart-shopping", + "medium" + ); + selectedProductsSection.appendChild(numberOfProducts); + + const refreshButton = CostUI.createButton("Refresh", "fa-solid fa-sync"); + refreshButton.addEventListener("click", function (e) { + e.preventDefault(); + callbacks.enableEditingQuantities(costItemId); + }); + selectedProductsSection.appendChild(refreshButton); + + const selectedProductsTable = CostUI.createProductTable({ + container: selectedProductsSection, + products: products, + quantityNames, + costItemId, + callbacks, + }); + return selectedProductsSection; + } + + static assignedProductsSection({ + products, + quantityNames, + costItemId, + callbacks, + }) { + const assignedProductsSection = document.createElement("div"); + assignedProductsSection.id = "assigned-products-section-" + costItemId; + assignedProductsSection.style.display = "none"; + assignedProductsSection.classList.add("form-section"); + if (products.length > 0) { + let text = " Assigned Products : " + products.length; + const assignedProductsText = CostUI.Text( + text, + "fa-solid fa-solid fa-paperclip", + "medium" + ); + assignedProductsSection.appendChild(assignedProductsText); + const assignmentsTable = CostUI.createAssignmentsTable({ + container: assignedProductsSection, + products: products, + quantityNames, + costItemId, + callbacks, + }); + } + + return assignedProductsSection; + } + + static manualQuantitiesSection({ + costItemId, + costQuantities, + has_assigned_products, + callbacks, + }) { + const manualQuantitiesSection = document.createElement("div"); + manualQuantitiesSection.classList.add("form-section"); + manualQuantitiesSection.id = "manual-quantities-section-" + costItemId; + manualQuantitiesSection.style.display = "none"; + + const title = CostUI.Text( + "Manual Quantities", + "fa-solid fa-ruler", + "medium" + ); + manualQuantitiesSection.appendChild(title); + const unitSymbol = costQuantities.unit_symbol; + + let quantityType = costQuantities.quantity_type; + + if (quantityType) { + const text = CostUI.Text( + quantityType + " (" + unitSymbol + " )", + "fa-solid fa-ruler", + "small" + ); + manualQuantitiesSection.appendChild(text); + } else { + const quantityTypes = [ + "IfcQuantityArea", + "IfcQuantityLength", + "IfcQuantityVolume", + "IfcQuantityCount", + "IfcQuantityWeight", + ]; + const dropdown = CostUI.createTableDropdown({ + name: "type", + options: quantityTypes, + defaultValue: "IfcQuantityArea", + }); + dropdown.id = "quantity-type-dropdown-" + costItemId; + manualQuantitiesSection.appendChild(dropdown); + } + if (quantityType === "IfcQuantityCount" && has_assigned_products) { + const text = CostUI.Text( + " One of the quantities is assigned to the product selection", + "fa-solid fa-warning", + "small" + ); + manualQuantitiesSection.appendChild(text); + } + + let paramaterQuantities = costQuantities.quantities.filter( + (quantity) => quantity.fromProduct + ); + let manualQuantities = costQuantities.quantities.filter( + (quantity) => !quantity.fromProduct + ); + + let headerNames = ["Name", "Value" + " (" + unitSymbol + " )", "Actions"]; + + if (manualQuantities.length > 0) { + headerNames = []; + Object.entries(manualQuantities[0]).forEach(([key, value]) => { + if (key !== "id" && key !== "fromProduct") { + headerNames.push(key); + } + }); + headerNames.push("Actions"); + } + + const { tableContainer, table } = CostUI.addTable({ + headers: headerNames, + className: "manual-quantities-table", + id: "manual-quantities-table-" + costItemId, + }); + + manualQuantities.forEach((quantity) => { + const tr = CostUI.createManualQuantityRow( + costItemId, + quantity, + callbacks + ); + table.appendChild(tr); + }); + const addButton = CostUI.createAddManualQuantityButton( + costItemId, + quantityType, + callbacks + ); + + manualQuantitiesSection.appendChild(tableContainer); + manualQuantitiesSection.appendChild(addButton); + + return manualQuantitiesSection; + } + + static QuantityQuerySection({ callbacks, costItemId }) { + const manualQuantityQuerySection = document.createElement("div"); + manualQuantityQuerySection.classList.add("form-section"); + manualQuantityQuerySection.id = "query-quantities-section-" + costItemId; + manualQuantityQuerySection.style.display = "none"; + + const title = CostUI.Text( + "Manual Quantities", + "fa-solid fa-ruler", + "medium" + ); + manualQuantityQuerySection.appendChild(title); + + const queryInput = document.createElement("input"); + queryInput.type = "text"; + queryInput.placeholder = "Enter a query"; + queryInput.id = "query-input-" + costItemId; + manualQuantityQuerySection.appendChild(queryInput); + + const propNameInput = document.createElement("input"); + propNameInput.type = "text"; + propNameInput.placeholder = "Enter a property name"; + propNameInput.id = "prop-name-input-" + costItemId; + manualQuantityQuerySection.appendChild(propNameInput); + + const queryButton = CostUI.createButton( + "Assign from Query", + "fa-solid fa-plus" + ); + queryButton.addEventListener("click", function (e) { + e.preventDefault(); + const query = queryInput.value; + const propName = propNameInput.value; + callbacks.assignFromQuery(costItemId, query, propName); + }); + + manualQuantityQuerySection.appendChild(queryButton); + return manualQuantityQuerySection; + } + + static createManualQuantityRow(costItemId, quantity, callbacks) { + const tr = document.createElement("tr"); + tr.id = quantity.id; + Object.entries(quantity).forEach(([key, value]) => { + if (key !== "id" && key !== "fromProduct") { + if (key === "Name") { + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = "text"; + input.value = value; + cell.appendChild(input); + tr.appendChild(cell); + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + callbacks.editQuantity(costItemId, quantity.id, { + [key]: input.value, + }); + } + }); + } else if (key.toLowerCase().includes("value")) { + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = "number"; + input.value = value; + cell.appendChild(input); + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + callbacks.editQuantity(costItemId, quantity.id, { + [key]: parseFloat(input.value), + }); + } + }); + tr.appendChild(cell); + } else { + const cell = document.createElement("td"); + cell.textContent = value; + tr.appendChild(cell); + } + } + }); + + const deleteButton = CostUI.createButton("Delete", "fa-solid fa-trash"); + const deleteCell = document.createElement("td"); + deleteButton.addEventListener("click", function (e) { + e.preventDefault(); + callbacks.deleteQuantity(costItemId, quantity.id); + tr.remove(); + }); + deleteCell.appendChild(deleteButton); + tr.appendChild(deleteCell); + + return tr; + } + + static createAddManualQuantityButton(costItemId, quantityType, callbacks) { + const addButton = CostUI.createButton( + "Add Manual Quantity", + "fa-solid fa-plus" + ); + + addButton.addEventListener("click", function (e) { + e.preventDefault(); + let type = CostUI.getQuantityTypeAttribute(costItemId, quantityType); + callbacks.addQuantity(costItemId, type); + }); + + return addButton; + } + + static getQuantityTypeAttribute(costItemId, quantityType) { + let type = quantityType; + if (!type) { + const qtoSection = document.getElementById( + "manual-quantities-section-" + costItemId + ); + if (!qtoSection) return null; + const dropdown = qtoSection.querySelector("select"); + type = dropdown ? dropdown.value : null; + } + return type; + } + + static createCostClassificationWindow({ + classificationName, + classificationElements, + costClassifications, + costItemId, + callbacks, + }) { + const formName = classificationName || "Classification"; + const formContainer = CostUI.Form({ + id: "classificationTree", + name: formName, + icon: "fa-solid fa-sitemap", + }); + this.createLayeredViewPanel( + formContainer, + classificationElements, + formName, + costItemId, + callbacks + ); + this.classificationList = this.createClassificationList({ + costClassifications, + costItemId, + callbacks, + }); + formContainer.appendChild(this.classificationList); + } + + static createClassificationList({ + costClassifications, + costItemId, + callbacks, + }) { + const classificationList = document.createElement("div"); + classificationList.classList.add("classification-list"); + + const header = document.createElement("h2"); + header.textContent = "Classifications"; + classificationList.appendChild(header); + + const classificationContainer = document.createElement("div"); + classificationContainer.classList.add("classification-container"); + classificationList.appendChild(classificationContainer); + if (costClassifications.length === 0) { + const text = "No classifications available"; + const noDataMessage = this.Text( + text, + "fa-solid fa-exclamation-circle", + "large" + ); + classificationContainer.appendChild(noDataMessage); + } else { + costClassifications.forEach((classification) => { + const classificationDiv = document.createElement("div"); + classificationDiv.classList.add("classification"); + classificationDiv.textContent = + classification.Name + " (" + classification.Identification + ")"; + + const removeClassificationReferenceButton = this.createButton( + "Remove", + "fa-solid fa-trash" + ); + removeClassificationReferenceButton.addEventListener( + "click", + function (event) { + event.preventDefault(); + callbacks.removeClassificationReference( + costItemId, + classification.id + ); + callbacks.enableEditingClassification(costItemId); + } + ); + classificationDiv.appendChild(removeClassificationReferenceButton); + classificationContainer.appendChild(classificationDiv); + }); + } + return classificationList; + } + + static createLayeredViewPanel( + formContainer, + classificationElements, + classificationName, + costItemId, + callbacks + ) { + function loadLevel(items) { + container.innerHTML = ""; + items.forEach((item) => { + const itemDiv = document.createElement("div"); + itemDiv.classList.add("item-container"); + itemDiv.classList.add("row-container"); + itemDiv.innerHTML = `${item.Identification}: ${item.Name}`; + + if (item.has_references && item.references.length > 0) { + const nextButton = CostUI.createButton( + "View", + "fa-solid fa-arrow-right" + ); + nextButton.addEventListener("click", function (event) { + event.stopPropagation(); + currentPath.push(item); + loadLevel(item.references); + backButton.disabled = false; + }); + itemDiv.appendChild(nextButton); + } + itemDiv.addEventListener("click", function () { + const allItems = container.querySelectorAll(".item-container"); + allItems.forEach((i) => i.classList.remove("selected")); + itemDiv.classList.add("selected"); + selectedClassification.textContent = `${item.Identification}: ${item.Name}`; + selectedClassification.currentSelection = item.id; + assignClassificationButton.disabled = false; + }); + + container.appendChild(itemDiv); + }); + } + + function goBack() { + currentPath.pop(); + const parent = currentPath.length + ? currentPath[currentPath.length - 1].references + : classificationElements; + loadLevel(parent); + + if (currentPath.length === 0) { + backButton.disabled = true; + } + } + let currentPath = []; + let currentData = classificationElements; + + const container = document.createElement("div"); + container.id = "classificationView"; + container.classList.add("classificationView"); + formContainer.appendChild(container); + + if (!classificationElements || classificationElements.length === 0) { + const text = + "No data available - please activate a classification file with BonsaiBIM "; + const noDataMessage = this.Text( + text, + "fa-solid fa-exclamation-circle", + "large" + ); + const refreshButton = this.createButton( + "Enable Editing Classification", + "fa-solid fa-arrows-rotate" + ); + refreshButton.addEventListener("click", function (event) { + event.preventDefault(); + callbacks.enableEditingClassification(costItemId); + }); + container.appendChild(refreshButton); + container.appendChild(noDataMessage); + return; + } + + const rowContainer = document.createElement("div"); + rowContainer.classList.add("row-container"); + + const selectedClassificationText = this.Text( + "Selected Classification: ", + "fa-solid fa-check", + "medium" + ); + const selectedClassification = document.createElement("span"); + selectedClassificationText.classList.add("selectedClassificationText"); + selectedClassification.classList.add( + "selected", + "row-container", + "item-container" + ); + selectedClassification.id = "selectedClassification"; + selectedClassification.currentSelection = null; + selectedClassificationText.appendChild(selectedClassification); + rowContainer.appendChild(selectedClassificationText); + + const assignClassificationButton = this.createButton( + "Assign Classification", + "fa-solid fa-check" + ); + assignClassificationButton.addEventListener("click", function (event) { + event.preventDefault(); + if (selectedClassification.currentSelection) { + callbacks.addClassificationReference( + costItemId, + classificationName, + selectedClassification.currentSelection + ); + callbacks.enableEditingClassification(costItemId); + } + }); + + assignClassificationButton.disabled = true; + rowContainer.appendChild(assignClassificationButton); + formContainer.appendChild(rowContainer); + + const backButton = CostUI.createButton("Back", "fa-solid fa-arrow-left"); + backButton.classList.add("back-button"); + backButton.addEventListener("click", function (event) { + event.preventDefault(); + if (!backButton.disabled) { + goBack(); + } + }); + + formContainer.insertBefore(backButton, container); + backButton.disabled = true; + + loadLevel(currentData); + return container; + } +} diff --git a/src/bonsai/bonsai/bim/data/webui/templates/costing.html b/src/bonsai/bonsai/bim/data/webui/templates/costing.html new file mode 100644 index 0000000000..0351bd978c --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/templates/costing.html @@ -0,0 +1,102 @@ + + + + + + BlenderBIM Web UI + + + + + + + + + + + +
+
+ +
+
+
+
+
+

BlenderBIM Version: {{version}}

+
+ + diff --git a/src/bonsai/bonsai/bim/data/webui/templates/demo.html b/src/bonsai/bonsai/bim/data/webui/templates/demo.html index 151a7948e8..da67d65d5c 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/demo.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/demo.html @@ -5,6 +5,11 @@ Bonsai Web UI + diff --git a/src/bonsai/bonsai/bim/data/webui/templates/drawings.html b/src/bonsai/bonsai/bim/data/webui/templates/drawings.html index 858f1edcf9..5b0bffaab4 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/drawings.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/drawings.html @@ -4,6 +4,11 @@ Bonsai Web UI + Schedules +
  • + Costing +
  • Construction Sequencing Bonsai Web UI + var SOCKET_PORT = {{port}}; - +
  • -
    +
    +

    Bonsai Version: {{version}}

    diff --git a/src/bonsai/bonsai/bim/data/webui/templates/index.html b/src/bonsai/bonsai/bim/data/webui/templates/index.html index 9afd84956b..d34f6e357f 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/index.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/index.html @@ -4,6 +4,11 @@ Bonsai Web UI + Schedules +
  • + Costing +
  • Construction Sequencing None: """you can set attribute active in popup with `active_attribute` meaning you will be able to type into attribute's field without having to click on it first @@ -44,7 +49,9 @@ def draw_attributes(props, layout, copy_operator=None, popup_active_attribute=No draw_attribute(attribute, row, copy_operator) -def draw_attribute(attribute, layout, copy_operator=None): +def draw_attribute( + attribute: bpy.types.PropertyGroup, layout: bpy.types.UILayout, copy_operator: Optional[str] = None +) -> None: value_name = attribute.get_value_name() if not value_name: layout.label(text=attribute.name) @@ -199,7 +206,7 @@ def export_attributes(props, callback: Optional[Callable] = None) -> dict[str, A return attributes -def prop_with_search(layout, data, prop_name, **kwargs): +def prop_with_search(layout, data, prop_name, should_click_ok_to_validate=False, **kwargs): # kwargs are layout.prop arguments (text, icon, etc.) row = layout.row(align=True) row.prop(data, prop_name, **kwargs) @@ -209,6 +216,7 @@ def prop_with_search(layout, data, prop_name, **kwargs): row.context_pointer_set(name="data", data=data) op = row.operator("bim.enum_property_search", text="", icon="VIEWZOOM") op.prop_name = prop_name + op.should_click_ok_to_validate = should_click_ok_to_validate except TypeError: # Prop is not iterable pass diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index da67773ace..358ef37aef 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -117,22 +117,34 @@ class IfcStore: ifc_key = IfcStore.path + IfcStore.file.wrapped_data.header.file_name.time_stamp ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest() IfcStore.cache_path = os.path.join(bpy.context.scene.BIMProperties.data_dir, "cache", f"{ifc_hash}.h5") + cache_path = Path(IfcStore.cache_path) cache_settings = ifcopenshell.geom.settings() serializer_settings = ifcopenshell.geom.serializer_settings() + cache_preexists = cache_path.exists() try: IfcStore.cache = ifcopenshell.geom.serializers.hdf5( IfcStore.cache_path, cache_settings, serializer_settings ) - except: - if os.path.exists(IfcStore.cache_path): - os.remove(IfcStore.cache_path) - try: - IfcStore.cache = ifcopenshell.geom.serializers.hdf5( - IfcStore.cache_path, cache_settings, serializer_settings - ) - except: - return + if cache_preexists: + print(f"Successfully loaded existing cache: {cache_path.name}.") else: + print("New cache was created.") + except Exception as e: + if cache_preexists: + print(f"Failed to create a cache from existing file '{cache_path.name}': {str(e)}.") + else: + print(f"Failed to create a cache: {str(e)}.") + # No point to trying again the same operation. + return + + os.remove(IfcStore.cache_path) + try: + IfcStore.cache = ifcopenshell.geom.serializers.hdf5( + IfcStore.cache_path, cache_settings, serializer_settings + ) + print("New cache was created.") + except Exception as e: + print(f"Failed to create a cache: {str(e)}.") return return IfcStore.cache @@ -140,6 +152,8 @@ class IfcStore: def update_cache(): if not IfcStore.cache: return + assert IfcStore.cache_path + 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.data_dir, "cache", f"{ifc_hash}.h5") diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index c5eae6c4d9..eb4cf3a50b 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -40,29 +40,37 @@ from typing import Dict, Union, Optional, Any class MaterialCreator: + mesh: bpy.types.Mesh + obj: bpy.types.Object + def __init__(self, ifc_import_settings: IfcImportSettings, ifc_importer: IfcImporter): - self.mesh: bpy.types.Mesh = None - self.obj: bpy.types.Object = None self.styles: Dict[int, bpy.types.Material] = {} self.parsed_meshes: set[str] = set() self.ifc_import_settings = ifc_import_settings self.ifc_importer = ifc_importer - def create(self, element: ifcopenshell.entity_instance, obj: bpy.types.Object, mesh: OBJECT_DATA_TYPE) -> None: - self.mesh = mesh - # as ifcopenshell triangulates the mesh, we need to merge it to quads again - self.obj = obj - if (hasattr(element, "Representation") and not element.Representation) or ( - hasattr(element, "RepresentationMaps") and not element.RepresentationMaps + def create( + self, + element: ifcopenshell.entity_instance, + obj: bpy.types.Object, + mesh: Union[OBJECT_DATA_TYPE, None], + shape_has_openings: bool, + ) -> None: + if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or ( + (rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep ): return - if not self.mesh or self.mesh.name in self.parsed_meshes: + + if not mesh or mesh.name in self.parsed_meshes: return # We don't support curve styles yet. if isinstance(mesh, bpy.types.Curve): return + self.mesh = mesh + self.obj = obj + # mesh["ios_materials"] can contain: # - ifc style id if style assigned to the representation items directly # or through material with a style; @@ -74,7 +82,7 @@ class MaterialCreator: # Though 0 value will not occur as we don't use default materials in IfcImporter. self.parsed_meshes.add(self.mesh.name) - self.load_texture_maps() + self.load_texture_maps(shape_has_openings) self.assign_material_slots_to_faces() tool.Geometry.record_object_materials(obj) del self.mesh["ios_materials"] @@ -100,14 +108,16 @@ class MaterialCreator: print(f"WARNING. IfcTextureMap texture coordinates is not supported.") return - def load_texture_maps(self) -> None: + def load_texture_maps(self, shape_has_openings: bool) -> None: for style_or_material_id in self.mesh["ios_materials"]: if not (material := self.styles.get(style_or_material_id)): continue material = self.styles[style_or_material_id] if coords := self.get_ifc_coordinate(material): - tool.Loader.load_indexed_texture_map(coords, self.mesh) + if shape_has_openings and coords.is_a("IfcIndexedTextureMap"): + continue + tool.Loader.load_indexed_map(coords, self.mesh) def assign_material_slots_to_faces(self) -> None: if not self.mesh["ios_materials"]: @@ -272,6 +282,7 @@ class IfcImporter: tool.Spatial.run_spatial_import_spatial_decomposition() if default_container := tool.Spatial.guess_default_container(): tool.Spatial.set_default_container(default_container) + tool.Loader.setup_active_bsdd_classification() self.update_progress(100) bpy.context.window_manager.progress_end() @@ -539,9 +550,6 @@ class IfcImporter: shape = tool.Loader.create_generic_shape(grid) grid_obj = self.create_product(grid, shape) grid_placement = self.get_element_matrix(grid) - if tool.Blender.get_addon_preferences().lock_grids_on_import: - grid_obj.lock_location = (True, True, True) - grid_obj.lock_rotation = (True, True, True) self.create_grid_axes(grid.UAxes, grid_obj, grid_placement) self.create_grid_axes(grid.VAxes, grid_obj, grid_placement) if grid.WAxes: @@ -552,9 +560,6 @@ class IfcImporter: 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) - if tool.Blender.get_addon_preferences().lock_grids_on_import: - obj.lock_location = (True, True, True) - obj.lock_rotation = (True, True, True) self.link_element(axis, obj) self.set_matrix_world(obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, grid_placement.copy())) @@ -576,7 +581,10 @@ class IfcImporter: mesh = self.meshes.get(mesh_name) if mesh is None: shape = tool.Loader.create_generic_shape(representation) - if shape: + # Skip elements without geometry - e.g. annotations with IfcTextLiterals. + if shape and not shape.verts_buffer: + pass + elif shape: mesh = self.create_mesh(element, shape) tool.Loader.link_mesh(shape, mesh) self.meshes[mesh_name] = mesh @@ -585,7 +593,7 @@ class IfcImporter: break obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh) self.link_element(element, obj) - self.material_creator.create(element, obj, mesh) + self.material_creator.create(element, obj, mesh, False) self.type_products[element.GlobalId] = obj def create_native_elements(self): @@ -711,13 +719,27 @@ class IfcImporter: products: set[ifcopenshell.entity_instance], settings: Optional[ifcopenshell.geom.main.settings] = None, ) -> set[ifcopenshell.entity_instance]: + checkpoint = time.time() results = set() if not products: return results + + # For efficiency (iterator.initialize takes time), return early if there are no representations + if not any(product.Representation for product in products): + return results + if tool.Loader.settings.should_use_cpu_multiprocessing: - iterator = ifcopenshell.geom.iterator(settings, self.file, multiprocessing.cpu_count(), include=products) + iterator = ifcopenshell.geom.iterator( + settings, + self.file, + multiprocessing.cpu_count(), + include=products, + geometry_library=self.ifc_import_settings.geometry_library, + ) else: - iterator = ifcopenshell.geom.iterator(settings, self.file, include=products) + iterator = ifcopenshell.geom.iterator( + settings, self.file, include=products, geometry_library=self.ifc_import_settings.geometry_library + ) if self.ifc_import_settings.should_cache: cache = IfcStore.get_cache() if cache: @@ -725,7 +747,6 @@ class IfcImporter: valid_file = iterator.initialize() if not valid_file: return results - checkpoint = time.time() progress = 0 total = len(products) start_progress = self.progress @@ -841,7 +862,7 @@ class IfcImporter: def create_product( self, element: ifcopenshell.entity_instance, - shape: Optional[Any] = None, + shape: Optional[Union[ifcopenshell.geom.ShapeElementType, ifcopenshell.geom.ShapeType]] = None, mesh: Optional[OBJECT_DATA_TYPE] = None, ) -> Union[bpy.types.Object, None]: if element is None: @@ -854,6 +875,10 @@ class IfcImporter: self.ifc_import_settings.logger.info("Creating object %s", element) + # Skip material creation if mesh already exists (e.g., when during drawing activation + # importing annotations that reuse their type meshes). + materials_updated = False + if mesh: pass elif element.is_a("IfcAnnotation") and self.is_curve_annotation(element) and shape: @@ -862,6 +887,7 @@ class IfcImporter: elif shape: mesh_name = tool.Loader.get_mesh_name_from_shape(shape.geometry) mesh = self.meshes.get(mesh_name) + materials_updated = bool(mesh) if mesh is None: mesh = self.create_mesh(element, shape) tool.Loader.link_mesh(shape, mesh) @@ -877,12 +903,13 @@ class IfcImporter: mat = np.array(shape.transformation.matrix).reshape((4, 4), order="F") self.set_matrix_world(obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, mat)) assert mesh # Type checker. - self.material_creator.create(element, obj, mesh) - elif mesh: + if not materials_updated: + self.material_creator.create(element, obj, mesh, tool.Geometry.does_shape_has_openings(shape)) + elif mesh: # When does this occur? self.set_matrix_world( obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element)) ) - self.material_creator.create(element, obj, mesh) + self.material_creator.create(element, obj, mesh, False) elif hasattr(element, "ObjectPlacement"): self.set_matrix_world( obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element)) @@ -1129,11 +1156,7 @@ class IfcImporter: except: # Occurs when reloading a project pass - project_collection = bpy.context.view_layer.layer_collection.children[self.project["blender"].name] - if types_collection := project_collection.children.get("IfcTypeProduct"): - types_collection.hide_viewport = False - for obj in types_collection.collection.objects: # turn off all objects inside Types collection. - obj.hide_set(True) + tool.Collector.reset_default_visibility() def clean_mesh(self): obj = None @@ -1147,19 +1170,11 @@ class IfcImporter: if not last_obj: return - # Temporarily unhide types collection to make sure all objects will be cleaned - project_collection = bpy.context.view_layer.layer_collection.children[self.project["blender"].name] - if types_collection := project_collection.children.get("IfcTypeProduct"): - types_collection.hide_viewport = False - bpy.context.view_layer.objects.active = last_obj - bpy.ops.object.editmode_toggle() bpy.ops.mesh.tris_convert_to_quads() bpy.ops.mesh.normals_make_consistent() bpy.ops.object.editmode_toggle() - if types_collection: - types_collection.hide_viewport = True bpy.context.view_layer.objects.active = last_obj IfcStore.edited_objs.clear() @@ -1174,8 +1189,9 @@ class IfcImporter: tool.Loader.set_unit_scale(self.unit_scale) def set_units(self): - units = self.file.by_type("IfcUnitAssignment")[0] - for unit in units.Units: + if not (units := self.file.by_type("IfcUnitAssignment")): + return # Geometry is optional in IFC + for unit in units[0].Units: if unit.is_a("IfcNamedUnit") and unit.UnitType == "LENGTHUNIT": if unit.is_a("IfcSIUnit"): bpy.context.scene.unit_settings.system = "METRIC" @@ -1336,6 +1352,7 @@ class IfcImporter: self, element: ifcopenshell.entity_instance, shape: Union[ifcopenshell.geom.ShapeElementType, ifcopenshell.geom.ShapeType], + cartesian_point_offset=None, ) -> bpy.types.Mesh: try: if hasattr(shape, "geometry"): @@ -1346,7 +1363,20 @@ class IfcImporter: mesh = bpy.data.meshes.new(tool.Loader.get_mesh_name_from_shape(geometry)) - if geometry.verts and tool.Loader.is_point_far_away( + if cartesian_point_offset: + verts_array = np.array(geometry.verts) + offset = np.array([-cartesian_point_offset[0], -cartesian_point_offset[1], -cartesian_point_offset[2]]) + offset_verts = verts_array + np.tile(offset, len(verts_array) // 3) + verts = offset_verts.tolist() + + mesh["has_cartesian_point_offset"] = True + mesh["cartesian_point_offset"] = ( + f"{cartesian_point_offset[0]},{cartesian_point_offset[1]},{cartesian_point_offset[2]}" + ) + elif cartesian_point_offset is False: + verts = geometry.verts + mesh["has_cartesian_point_offset"] = False + elif geometry.verts and tool.Loader.is_point_far_away( (geometry.verts[0], geometry.verts[1], geometry.verts[2]), is_meters=True ): # Shift geometry close to the origin based off that first vert it found @@ -1361,40 +1391,7 @@ class IfcImporter: verts = geometry.verts mesh["has_cartesian_point_offset"] = False - if geometry.faces: - num_vertices = len(verts) // 3 - total_faces = len(geometry.faces) - loop_start = range(0, total_faces, 3) - num_loops = total_faces // 3 - loop_total = [3] * num_loops - num_vertex_indices = len(geometry.faces) - - # See bug 3546 - # ios_edges holds true edges that aren't triangulated. - # - # we do `.tolist()` because Blender can't assign `np.int32` to it's custom attributes - mesh["ios_edges"] = list(set(tuple(e) for e in ifcopenshell.util.shape.get_edges(geometry).tolist())) - mesh["ios_item_ids"] = ifcopenshell.util.shape.get_representation_item_ids(geometry).tolist() - - mesh.vertices.add(num_vertices) - mesh.vertices.foreach_set("co", verts) - mesh.loops.add(num_vertex_indices) - mesh.loops.foreach_set("vertex_index", geometry.faces) - mesh.polygons.add(num_loops) - mesh.polygons.foreach_set("loop_start", loop_start) - mesh.polygons.foreach_set("loop_total", loop_total) - mesh.polygons.foreach_set("use_smooth", [0] * total_faces) - mesh.update() - else: - e = geometry.edges - v = verts - vertices = [[v[i], v[i + 1], v[i + 2]] for i in range(0, len(v), 3)] - edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)] - mesh.from_pydata(vertices, edges, []) - - mesh["ios_materials"] = [m.instance_id() for m in geometry.materials] - mesh["ios_material_ids"] = geometry.material_ids - return mesh + return tool.Loader.convert_geometry_to_mesh(geometry, mesh, verts=verts) except: self.ifc_import_settings.logger.error("Could not create mesh for %s", element) import traceback @@ -1459,13 +1456,17 @@ class IfcImporter: bpy.ops.object.select_all(action="DESELECT") def setup_arrays(self): - for element in self.file.by_type("IfcElement"): - pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Array") - if not pset_data or not pset_data.get("Data", None): # skip array children + for pset in self.file.by_type("IfcPropertySet"): + if pset.Name != "BBIM_Array": continue - for i in range(len(json.loads(pset_data["Data"]))): - tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + if not (data := ifcopenshell.util.element.get_property_definition(pset, "Data")): + continue + data = json.loads(data) + for rel in pset.DefinesOccurrence: + for element in rel.RelatedObjects: + for i in range(len(data)): + tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) + tool.Blender.Modifier.Array.constrain_children_to_parent(element) class IfcImportSettings: @@ -1473,6 +1474,7 @@ class IfcImportSettings: self.logger: logging.Logger = None self.input_file = None self.diff_file = None + self.geometry_library = "opencascade" self.should_use_cpu_multiprocessing = True self.should_merge_materials_by_colour = False self.should_load_geometry = True @@ -1508,6 +1510,7 @@ class IfcImportSettings: logger = logging.getLogger("ImportIFC") settings.logger = logger settings.diff_file = scene_diff.diff_json_file + settings.geometry_library = props.geometry_library settings.should_use_cpu_multiprocessing = props.should_use_cpu_multiprocessing settings.should_merge_materials_by_colour = props.should_merge_materials_by_colour settings.should_load_geometry = props.should_load_geometry diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 187d1cb648..d25343fad8 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -76,7 +76,7 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - for obj in bpy.context.selected_objects: + for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) if not element: continue diff --git a/src/bonsai/bonsai/bim/module/attribute/operator.py b/src/bonsai/bonsai/bim/module/attribute/operator.py index 24102a66ef..e04addce81 100644 --- a/src/bonsai/bonsai/bim/module/attribute/operator.py +++ b/src/bonsai/bonsai/bim/module/attribute/operator.py @@ -107,6 +107,9 @@ class EditAttributes(bpy.types.Operator, tool.Ifc.Operator): attributes = bonsai.bim.helper.export_attributes(props.attributes, callback=callback) ifcopenshell.api.run("attribute.edit_attributes", self.file, product=product, attributes=attributes) + + if (name := tool.Loader.get_name(product)) and obj.name != name: + obj.name = name bpy.ops.bim.disable_editing_attributes(obj=obj.name) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index 92bceb930b..d29c50cf69 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -391,14 +391,11 @@ class DisableEditingBoundary(bpy.types.Operator): return {"FINISHED"} -class EditBoundaryAttributes(bpy.types.Operator): +class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_boundary_attributes" bl_label = "Disable Editing Boundary Relations" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): bprops = context.active_object.bim_boundary_properties boundary = tool.Ifc.get_entity(context.active_object) @@ -412,7 +409,7 @@ class EditBoundaryAttributes(bpy.types.Operator): return {"FINISHED"} -class UpdateBoundaryGeometry(bpy.types.Operator): +class UpdateBoundaryGeometry(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.update_boundary_geometry" bl_label = "Update Boundary Geometry" bl_description = """ @@ -421,9 +418,6 @@ class UpdateBoundaryGeometry(bpy.types.Operator): """ bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - 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) diff --git a/src/bonsai/bonsai/bim/module/bsdd/operator.py b/src/bonsai/bonsai/bim/module/bsdd/operator.py index 0dadf3c354..3ae6e15493 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/operator.py +++ b/src/bonsai/bonsai/bim/module/bsdd/operator.py @@ -34,7 +34,7 @@ class LoadBSDDDomains(bpy.types.Operator): class SetActiveBSDDDictionary(bpy.types.Operator): bl_idname = "bim.set_active_bsdd_domain" - bl_label = "Load bSDD Dictionary" + bl_label = "Set bSDD Dictionary as active" bl_options = {"REGISTER", "UNDO"} name: bpy.props.StringProperty() uri: bpy.props.StringProperty() @@ -50,6 +50,14 @@ class SearchBSDDClass(bpy.types.Operator): bl_description = "Search for bSDD classes by the provided keyword" bl_options = {"REGISTER", "UNDO"} + @classmethod + def poll(cls, context): + # Requirement by buildingSMART bSDD api. + if len(context.scene.BIMBSDDProperties.keyword) < 3: + cls.poll_message_set("Search query has to be at least 3 characters long.") + return False + return True + def execute(self, context): keyword = context.scene.BIMBSDDProperties.keyword classes_found = core.search_class(keyword, bsdd.Client(), tool.Bsdd) @@ -60,9 +68,7 @@ class SearchBSDDClass(bpy.types.Operator): class GetBSDDClassificationProperties(bpy.types.Operator): bl_idname = "bim.get_bsdd_classification_properties" bl_label = "Search bSDD Class Properties" - bl_description = ( - "Search for bSDD class properties for the currently selected class. All non-IFC properties are skipped" - ) + bl_description = "Search for bSDD class properties for the currently selected class" bl_options = {"REGISTER", "UNDO"} def execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/bsdd/prop.py b/src/bonsai/bonsai/bim/module/bsdd/prop.py index 0ab20d439b..5a6090b12b 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/prop.py +++ b/src/bonsai/bonsai/bim/module/bsdd/prop.py @@ -60,12 +60,17 @@ class BIMBSDDProperties(PropertyGroup): active_domain_index: IntProperty(name="Active Domain Index") classifications: CollectionProperty(name="Classifications", type=BSDDClassification) active_classification_index: IntProperty(name="Active Classification Index") - keyword: StringProperty(name="Keyword") + keyword: StringProperty(name="Keyword", description="Query for bsdd classes search, case and accent insensitive") should_filter_ifc_class: BoolProperty( name="Filter Active IFC Class", description="Whether to search only for bSDD classes that match active object's IFC class", default=True, ) + use_only_ifc_properties: BoolProperty( + name="Only IFC Properties", + description="Whether to display and assign only properties from IFC dictionary", + default=True, + ) load_preview_domains: BoolProperty( name="Load Preview Domains", description="Whether it should load preview and inactive domains", default=False ) diff --git a/src/bonsai/bonsai/bim/module/cad/__init__.py b/src/bonsai/bonsai/bim/module/cad/__init__.py index 3f0ac8e336..601c4da1c3 100644 --- a/src/bonsai/bonsai/bim/module/cad/__init__.py +++ b/src/bonsai/bonsai/bim/module/cad/__init__.py @@ -40,8 +40,12 @@ def register(): bpy.utils.register_tool(workspace.CadTool, after={"builtin.transform"}, separator=True, group=False) bpy.types.Scene.BIMCadProperties = bpy.props.PointerProperty(type=prop.BIMCadProperties) + workspace.load_custom_icons() + def unregister(): if not bpy.app.background: bpy.utils.unregister_tool(workspace.CadTool) del bpy.types.Scene.BIMCadProperties + + workspace.unload_custom_icons() diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py index 31037abc1f..32dfde77e7 100644 --- a/src/bonsai/bonsai/bim/module/cad/operator.py +++ b/src/bonsai/bonsai/bim/module/cad/operator.py @@ -495,6 +495,7 @@ class CadOffset(bpy.types.Operator): normals = [] v1co = (wp.inverted() @ mw @ v1.co).to_2d() + if v2: v2co = (wp.inverted() @ mw @ v2.co).to_2d() direction = (v2co - v1co).normalized() diff --git a/src/bonsai/bonsai/bim/module/cad/workspace.py b/src/bonsai/bonsai/bim/module/cad/workspace.py index 501fbdb11f..d5027a39fe 100644 --- a/src/bonsai/bonsai/bim/module/cad/workspace.py +++ b/src/bonsai/bonsai/bim/module/cad/workspace.py @@ -25,10 +25,48 @@ from bpy.types import WorkSpaceTool from bonsai.bim.module.model.data import AuthoringData, RailingData, RoofData +def check_display_mode(): + global display_mode + try: + theme = bpy.context.preferences.themes["Default"] + text_color = theme.user_interface.wcol_menu_item.text + background_color = theme.user_interface.wcol_menu_item.outline + print(f"text_color = {text_color}") + print(f"background_color = {background_color}") + if sum(text_color) < 2.6: + display_mode = "lm" + else: + display_mode = "dm" + except: + display_mode = "dm" + + +def load_custom_icons(): + global custom_icon_previews + if display_mode is None: + check_display_mode() + + icons_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "icons") + custom_icon_previews = bpy.utils.previews.new() + + prefix = f"{display_mode}_" + + for entry in os.scandir(icons_dir): + if entry.name.endswith(".png") and entry.name.startswith(prefix): + name = os.path.splitext(entry.name)[0].replace(prefix, "", 1) + custom_icon_previews.load(name.upper(), entry.path, "IMAGE") + + +def unload_custom_icons(): + global custom_icon_previews + if custom_icon_previews: + bpy.utils.previews.remove(custom_icon_previews) + custom_icon_previews = None + + class CadTool(WorkSpaceTool): bl_space_type = "VIEW_3D" bl_context_mode = "EDIT_MESH" - bl_idname = "bim.cad_tool" bl_label = "CAD Tool" bl_description = "Gives you CAD authoring related superpowers" @@ -47,93 +85,69 @@ class CadTool(WorkSpaceTool): ) def draw_settings(context, layout, workspace_tool): + ui_context = str(context.region.type) obj = context.active_object if not obj or not obj.data: return if hasattr(obj.data, "BIMMeshProperties") and obj.data.BIMMeshProperties.subshape_type == "PROFILE": - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_Q") element = tool.Ifc.get_entity(obj) if element: if element.is_a("IfcProfileDef"): - row.operator("bim.edit_arbitrary_profile", text="Save Profile") - row.operator("bim.align_view_to_profile", text="", icon="AXIS_FRONT") - row.operator("bim.disable_editing_arbitrary_profile", text="", icon="CANCEL") + add_header_apply_button( + layout, + "Edit Profile", + "bim.edit_arbitrary_profile", + "bim.disable_editing_arbitrary_profile", + ui_context, + ) elif element.is_a("IfcRelSpaceBoundary"): - row.operator("bim.edit_boundary_geometry", text="Save Profile") - row.operator("bim.disable_editing_boundary_geometry", text="", icon="CANCEL") + add_header_apply_button( + layout, + "Edit Boundary Geometry", + "bim.edit_boundary_geometry", + "bim.disable_editing_boundary_geometry", + ui_context, + ) else: - row.operator("bim.edit_extrusion_profile", text="Save Profile") - row.operator("bim.align_view_to_profile", text="", icon="AXIS_FRONT") - row.operator("bim.disable_editing_extrusion_profile", text="", icon="CANCEL") + add_header_apply_button( + layout, + "Edit Profile", + "bim.edit_extrusion_profile", + "bim.disable_editing_extrusion_profile", + ui_context, + ) row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_E") - row.operator("bim.cad_hotkey", text="Extend").hotkey = "S_E" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_T") - row.operator("bim.cad_hotkey", text="Mitre").hotkey = "S_T" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_F") - row.operator("bim.cad_hotkey", text="Fillet").hotkey = "S_F" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_O") - row.operator("bim.cad_hotkey", text="Offset").hotkey = "S_O" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_R") - row.operator("bim.add_rectangle", text="Rectangle") - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_C") - row.operator("bim.add_ifccircle", text="Circle") - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_V") - row.operator("bim.set_arc_index", text="3-Point Arc") - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_X") - row.operator("bim.reset_vertex", text="Reset Vertex") + add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator( + 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_V", "Fillet", 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) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator(row, "Rectangle", "S_R", "Rectangle", 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) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator(row, "3-Point Arc", "S_V", "3-Point Arc", ui_context) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator(row, "Reset Vertex", "S_X", "Reset Vertex", ui_context) elif hasattr(obj.data, "BIMMeshProperties") and obj.data.BIMMeshProperties.subshape_type == "AXIS": + add_header_apply_button(layout, "Edit Axis", "bim.set_arc_index", "bim.set_arc_index", ui_context) row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_Q") - row.operator("bim.edit_extrusion_axis", text="Save Axis") - row.operator("bim.disable_editing_extrusion_axis", text="", icon="CANCEL") - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_E") - row.operator("bim.cad_hotkey", text="Extend").hotkey = "S_E" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_T") - row.operator("bim.cad_hotkey", text="Mitre").hotkey = "S_T" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_F") - row.operator("bim.cad_hotkey", text="Fillet").hotkey = "S_F" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_O") - row.operator("bim.cad_hotkey", text="Offset").hotkey = "S_O" + add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator( + 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) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator(row, "Offset", "S_O", "Offset", ui_context) else: if ( @@ -141,54 +155,39 @@ class CadTool(WorkSpaceTool): and RailingData.data["pset_data"] and context.active_object.BIMRailingProperties.is_editing_path ): - row = layout.row(align=True) - row.label(text="", icon=f"EVENT_TAB") - row.operator("bim.finish_editing_railing_path") - row.operator("bim.cancel_editing_railing_path", icon="CANCEL", text="") + add_header_apply_button( + layout, + "Edit Railing Path", + "bim.finish_editing_railing_path", + "bim.cancel_editing_railing_path", + ui_context, + ) elif ( (RoofData.is_loaded or not RoofData.load()) and RoofData.data["pset_data"] and context.active_object.BIMRoofProperties.is_editing_path ): + add_header_apply_button( + layout, "Edit Roof Path", "bim.finish_editing_roof_path", "bim.cancel_editing_roof_path", ui_context + ) row = layout.row(align=True) - row.label(text="", icon=f"EVENT_TAB") - row.operator("bim.finish_editing_roof_path") - row.operator("bim.cancel_editing_roof_path", icon="CANCEL", text="") - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_R") - row.operator("bim.cad_hotkey", text="Set Gable Roof Angle").hotkey = "S_R" + add_layout_hotkey_operator(row, "Set Gable Roof Angle", "S_R", "Set Gable Roof Angle", ui_context) row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_E") - row.operator("bim.cad_hotkey", text="Extend").hotkey = "S_E" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_T") - row.operator("bim.cad_hotkey", text="Mitre").hotkey = "S_T" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_F") - row.operator("bim.cad_hotkey", text="Fillet").hotkey = "S_F" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_O") - row.operator("bim.cad_hotkey", text="Offset").hotkey = "S_O" - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="2-Point Arc", icon="EVENT_C") - - row = layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_V") - row.operator("bim.cad_hotkey", text="3-Point Arc").hotkey = "S_V" + add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator( + 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_V", "Fillet", 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) + 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) + row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) + add_layout_hotkey_operator(row, "3-Point Arc", "S_V", "3-Point Arc", ui_context) class CadHotkey(bpy.types.Operator): @@ -196,6 +195,11 @@ class CadHotkey(bpy.types.Operator): bl_label = "CAD Hotkey" bl_options = {"REGISTER", "UNDO"} hotkey: bpy.props.StringProperty() + description: bpy.props.StringProperty() + + @classmethod + def description(cls, context, operator): + return operator.description or "" def execute(self, context): self.props = context.scene.BIMCadProperties @@ -306,3 +310,53 @@ class CadHotkey(bpy.types.Operator): and hasattr(obj.data, "BIMMeshProperties") and obj.data.BIMMeshProperties.subshape_type == "PROFILE" ) + + +def add_header_apply_button(layout, text, apply_operator, cancel_operator, ui_context=""): + 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) + if apply_operator in ("bim.edit_arbitrary_profile", "bim.edit_extrusion_profile"): + row.operator( + "bim.align_view_to_profile", text="Align View" if ui_context != "TOOL_HEADER" else "", icon="AXIS_FRONT" + ) + + row = layout.row(align=True) + row.operator(apply_operator, text="Apply" if ui_context != "TOOL_HEADER" else "", icon="CHECKMARK") + row.label(text="", icon="EVENT_TAB") if ui_context != "TOOL_HEADER" else row + row = layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator(cancel_operator, text="Cancel" if ui_context != "TOOL_HEADER" else "", icon="CANCEL") + + row = layout.row(align=True) + row.label(text="Tools") + + +def add_layout_hotkey_operator(layout, text, hotkey, description, ui_context=""): + 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 + + +custom_icon_previews = None +display_mode = None + +MODIFIERS = { + "S": ("EVENT_SHIFT", "⇧"), +} diff --git a/src/bonsai/bonsai/bim/module/classification/operator.py b/src/bonsai/bonsai/bim/module/classification/operator.py index 6ba4e7976b..bed077d3e6 100644 --- a/src/bonsai/bonsai/bim/module/classification/operator.py +++ b/src/bonsai/bonsai/bim/module/classification/operator.py @@ -404,6 +404,10 @@ class AddClassificationReference(bpy.types.Operator, tool.Ifc.Operator): class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_classification_reference_from_bsdd" bl_label = "Add Classification Reference From bSDD" + bl_description = ( + "Add classification reference from BSDD to the selected objects. " + "If class properties are loaded, they also will be assigned as psets/qsets" + ) bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() obj_type: bpy.props.StringProperty() @@ -418,6 +422,7 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): objects = [self.obj] props = context.scene.BIMClassificationProperties bprops = context.scene.BIMBSDDProperties + use_only_ifc_properties: bool = bprops.use_only_ifc_properties bsdd_classification = bprops.classifications[bprops.active_classification_index] @@ -451,6 +456,23 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): reference.Location = bsdd_classification.uri for classification_pset in bprops.classification_psets: + properties = {} + + blender_properties = classification_pset.properties + if use_only_ifc_properties: + blender_properties = [p for p in blender_properties if p.metadata == "IFC"] + + for prop in blender_properties: + properties[prop.name] = prop.get_value() + + if classification_pset.name == "undefined_set": + if "ObjectType" in properties: + if hasattr(element, "ObjectType"): + element.ObjectType = properties["ObjectType"] + del properties["ObjectType"] + if not properties: + continue + is_pset = not classification_pset.name.startswith("Qto_") if is_pset: @@ -469,10 +491,6 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): "pset.add_qto", tool.Ifc.get(), product=element, name=classification_pset.name ) - properties = {} - for prop in classification_pset.properties: - properties[prop.name] = prop.get_value() - if is_pset: ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=pset, properties=properties) else: diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index f6b429619f..300cfc5b8c 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.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 import bonsai.bim.helper import bonsai.tool as tool import bonsai.bim.module.classification.prop as classification_prop @@ -116,6 +117,8 @@ class BIM_PT_classifications(Panel): class ReferenceUI: + layout: bpy.types.UILayout + def draw_ui(self, context): obj = context.active_object self.sprops = context.scene.BIMClassificationProperties @@ -175,6 +178,7 @@ class ReferenceUI: row = self.layout.row() row.prop(self.bprops, "should_filter_ifc_class") + row.prop(self.bprops, "use_only_ifc_properties") if len(self.bprops.classifications): self.layout.template_list( @@ -199,11 +203,18 @@ class ReferenceUI: row.operator("bim.get_bsdd_classification_properties", text="", icon="COPY_ID") if len(self.bprops.classification_psets): + use_only_ifc_properties = self.bprops.use_only_ifc_properties for pset in self.bprops.classification_psets: + properties = pset.properties + if use_only_ifc_properties: + properties = [p for p in properties if p.metadata == "IFC"] + if not properties: + continue + box = self.layout.box() row = box.row() row.label(text=pset.name, icon="COPY_ID") - bonsai.bim.helper.draw_attributes(pset.properties, box) + bonsai.bim.helper.draw_attributes(properties, box) def draw_add_file_ui(self, context): if not self.data.data["active_classification_library"]: diff --git a/src/bonsai/bonsai/bim/module/constraint/operator.py b/src/bonsai/bonsai/bim/module/constraint/operator.py index 78c48775e0..ccd203ee15 100644 --- a/src/bonsai/bonsai/bim/module/constraint/operator.py +++ b/src/bonsai/bonsai/bim/module/constraint/operator.py @@ -77,14 +77,11 @@ class DisableEditingConstraint(bpy.types.Operator): return {"FINISHED"} -class AddObjective(bpy.types.Operator): +class AddObjective(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_objective" bl_label = "Add Objective" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): result = ifcopenshell.api.run("constraint.add_objective", IfcStore.get_file()) bpy.ops.bim.load_objectives() @@ -92,14 +89,11 @@ class AddObjective(bpy.types.Operator): return {"FINISHED"} -class EditObjective(bpy.types.Operator): +class EditObjective(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_objective" bl_label = "Edit Objective" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): props = context.scene.BIMConstraintProperties attributes = {} @@ -120,15 +114,12 @@ class EditObjective(bpy.types.Operator): return {"FINISHED"} -class RemoveConstraint(bpy.types.Operator): +class RemoveConstraint(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_constraint" bl_label = "Remove Constraint" bl_options = {"REGISTER", "UNDO"} constraint: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): props = context.scene.BIMConstraintProperties self.file = IfcStore.get_file() @@ -168,16 +159,13 @@ class DisableAssigningConstraint(bpy.types.Operator): return {"FINISHED"} -class AssignConstraint(bpy.types.Operator): +class AssignConstraint(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_constraint" bl_label = "Assign Constraint" bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() constraint: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = tool.Ifc.get() objs = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects @@ -194,16 +182,13 @@ class AssignConstraint(bpy.types.Operator): return {"FINISHED"} -class UnassignConstraint(bpy.types.Operator): +class UnassignConstraint(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_constraint" bl_label = "Unassign Constraint" bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() constraint: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = tool.Ifc.get() objs = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects diff --git a/src/bonsai/bonsai/bim/module/cost/__init__.py b/src/bonsai/bonsai/bim/module/cost/__init__.py index 212e3240a8..dbab9c8211 100644 --- a/src/bonsai/bonsai/bim/module/cost/__init__.py +++ b/src/bonsai/bonsai/bim/module/cost/__init__.py @@ -79,6 +79,7 @@ classes = ( operator.SelectUnassignedProducts, operator.UnassignCostItemQuantity, operator.UnassignCostItemType, + operator.GenerateCostScheduleBrowser, prop.CostItem, prop.CostItemQuantity, prop.CostItemType, diff --git a/src/bonsai/bonsai/bim/module/cost/data.py b/src/bonsai/bonsai/bim/module/cost/data.py index 2fef675a54..1630b49748 100644 --- a/src/bonsai/bonsai/bim/module/cost/data.py +++ b/src/bonsai/bonsai/bim/module/cost/data.py @@ -265,16 +265,10 @@ class CostSchedulesData: @classmethod def cost_values(cls): - results = [] ifc_id = bpy.context.scene.BIMCostProperties.active_cost_item_id if not ifc_id: - return results - cost_item = tool.Ifc.get().by_id(ifc_id) - for cost_value in cost_item.CostValues or []: - label = "{0:.2f}".format(ifcopenshell.util.cost.calculate_applied_value(cost_item, cost_value)) - label += " = {}".format(ifcopenshell.util.cost.serialise_cost_value(cost_value)) - results.append({"id": cost_value.id(), "label": label, "name": cost_value.Name}) - return results + return [] + return ifcopenshell.util.cost.get_cost_values(tool.Ifc.get().by_id(ifc_id)) @classmethod def quantity_types(cls): @@ -285,19 +279,8 @@ class CostSchedulesData: @classmethod def get_cost_schedule_types(cls): - results = [] - declaration = tool.Ifc().schema().declaration_by_name("IfcCostSchedule") - version = tool.Ifc.get_schema() - for attribute in declaration.attributes(): - if attribute.name() == "PredefinedType": - results.extend( - [ - (e, e, get_predefined_type_doc(version, "IfcCostSchedule", e)) - for e in attribute.type_of_attribute().declared_type().enumeration_items() - ] - ) - break - return results + types = ifcopenshell.util.cost.get_cost_schedule_types(tool.Ifc.get()) + return [(t["name"], t["name"], t["description"]) for t in types] class CostItemRatesData: @@ -335,18 +318,9 @@ class CostItemQuantitiesData: @classmethod def product_quantity_names(cls): - total_selected_objects = len(bpy.context.selected_objects) - names = set() - for obj in bpy.context.selected_objects: - element = tool.Ifc.get_entity(obj) - if not element: - continue - potential_names = set() - qtos = ifcopenshell.util.element.get_psets(element, qtos_only=True) - for qset, quantities in qtos.items(): - potential_names.update(quantities.keys()) - names = names.intersection(potential_names) if names else potential_names - return [(n, n, "") for n in names if n != "id"] + elements = tool.Spatial.get_selected_products() + names = ifcopenshell.util.cost.get_product_quantity_names(elements) + return [(n, n, "") for n in names] @classmethod def process_quantity_names(cls): diff --git a/src/bonsai/bonsai/bim/module/cost/operator.py b/src/bonsai/bonsai/bim/module/cost/operator.py index 4d3f43de01..45b78c89a7 100644 --- a/src/bonsai/bonsai/bim/module/cost/operator.py +++ b/src/bonsai/bonsai/bim/module/cost/operator.py @@ -518,6 +518,14 @@ class ImportCostScheduleCsv(bpy.types.Operator, ImportHelper, tool.Ifc.Operator) filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"}) is_schedule_of_rates: bpy.props.BoolProperty(name="Is Schedule Of Rates", default=False) + @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 + return True + def _execute(self, context): core.import_cost_schedule_csv(tool.Cost, self.filepath, self.is_schedule_of_rates) return {"FINISHED"} @@ -790,3 +798,14 @@ class AddCurrency(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): core.add_currency(tool.Ifc, tool.Cost) + + +class GenerateCostScheduleBrowser(bpy.types.Operator): + bl_idname = "bim.generate_cost_schedule_browser" + bl_label = "Generate Cost Schedule Browser" + bl_options = {"REGISTER", "UNDO"} + cost_schedule: bpy.props.IntProperty() + + def execute(self, context): + core.generate_cost_schedule_browser(tool.Cost, cost_schedule=tool.Ifc.get().by_id(self.cost_schedule)) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/cost/ui.py b/src/bonsai/bonsai/bim/module/cost/ui.py index 0233fd4f10..7be6605318 100644 --- a/src/bonsai/bonsai/bim/module/cost/ui.py +++ b/src/bonsai/bonsai/bim/module/cost/ui.py @@ -73,7 +73,12 @@ class BIM_PT_cost_schedules(Panel): row1.label(text="Schedule tools") row1 = col.row(align=True) row1.alignment = "RIGHT" - row1.operator("bim.export_cost_schedules", text="Export", icon="EXPORT").cost_schedule = cost_schedule["id"] + row1.operator("bim.export_cost_schedules", text="Export spreadsheet", icon="EXPORT").cost_schedule = ( + cost_schedule["id"] + ) + row1.operator( + "bim.generate_cost_schedule_browser", text="Generate spreadsheet browsser", icon="URL" + ).cost_schedule = cost_schedule["id"] row2 = col.row(align=True) row2.alignment = "RIGHT" op = row2.operator("bim.select_cost_schedule_products", icon="RESTRICT_SELECT_OFF", text="Assigned") diff --git a/src/bonsai/bonsai/bim/module/csv/operator.py b/src/bonsai/bonsai/bim/module/csv/operator.py index 480dae7458..c02fb1e7f6 100644 --- a/src/bonsai/bonsai/bim/module/csv/operator.py +++ b/src/bonsai/bonsai/bim/module/csv/operator.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 @@ -28,6 +29,11 @@ import bonsai.tool as tool import bonsai.bim.module.drawing.scheduler as scheduler from bonsai.bim.ifc import IfcStore from bonsai.bim.handler import refresh_ui_data +from typing import TYPE_CHECKING +from collections import Counter + +if TYPE_CHECKING: + import pandas as pd class AddCsvAttribute(bpy.types.Operator): @@ -183,6 +189,14 @@ class ExportIfcCsv(bpy.types.Operator): filename_ext = ".csv" filepath: bpy.props.StringProperty(subtype="FILE_PATH") + @classmethod + def poll(cls, context): + props = context.scene.CsvProperties + 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 + def invoke(self, context, event): props = context.scene.CsvProperties if props.format == "web": @@ -256,25 +270,46 @@ class ExportIfcCsv(bpy.types.Operator): if props.format == "web": if not context.scene.WebProperties.is_connected: bpy.ops.bim.connect_websocket_server() - tool.Web.send_webui_data(data=ifc_csv.dataframe.to_csv(index=False), data_key="csv_data", event="csv_data") + df = ifc_csv.dataframe + assert df is not None + # Tabulator seems to be ignoring columns non-unique columns, + # so we ensure they are unique at input. + df.columns = self.get_unique_column_names(df) + tool.Web.send_webui_data(data=df.to_csv(index=False), data_key="csv_data", event="csv_data") self.report({"INFO"}, f"Data is exported to {props.format.upper()}.") return {"FINISHED"} + def get_unique_column_names(self, dataframe: pd.DataFrame) -> list[str]: + count = Counter() + return [ + f"{col}.{i:03d}" if duped and not count.update([col]) and (i := count[col]) else col + for col, duped in zip(dataframe.columns, dataframe.columns.duplicated()) + ] -class ImportIfcCsv(bpy.types.Operator): + +class ImportIfcCsv(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.import_ifccsv" bl_label = "Import to IFC" + 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 + 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 + 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"} - def execute(self, context): + def _execute(self, context): import ifccsv props = context.scene.CsvProperties diff --git a/src/bonsai/bonsai/bim/module/debug/__init__.py b/src/bonsai/bonsai/bim/module/debug/__init__.py index 629bb05dc6..1e19ff678a 100644 --- a/src/bonsai/bonsai/bim/module/debug/__init__.py +++ b/src/bonsai/bonsai/bim/module/debug/__init__.py @@ -36,6 +36,7 @@ classes = ( operator.ProfileImportIFC, operator.PurgeHdf5Cache, operator.PurgeUnusedElementsByClass, + operator.PurgeUnusedObjects, operator.RestartBlender, operator.RewindInspector, operator.SelectExpressFile, diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index c01546b0ab..415db7d2b3 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -32,11 +32,14 @@ import ifcopenshell.util.representation import ifcopenshell.util.unit import bonsai.tool as tool import bonsai.core.debug as core +import bonsai.core.profile +import bonsai.core.type import bonsai.bim.handler import bonsai.bim.import_ifc as import_ifc from pathlib import Path from bonsai import get_debug_info, format_debug_info from bonsai.bim.ifc import IfcStore +from typing import get_args class CopyDebugInformation(bpy.types.Operator): @@ -231,15 +234,22 @@ class CreateShapeFromStepId(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} should_include_curves: bpy.props.BoolProperty() step_id: bpy.props.IntProperty(default=0) + geometry_library: bpy.props.EnumProperty( + name="Geometry Library", + items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)], + default="opencascade", + ) + custom_geometry_library: bpy.props.StringProperty( + name="Custom Geometry Library", + description="Provide a custom geometry library name, will override the 'geometry library' property.", + ) @classmethod def poll(cls, context): return IfcStore.get_file() def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - - def _execute(self, context): + geometry_library = self.custom_geometry_library or self.geometry_library logger = logging.getLogger("ImportIFC") self.ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger) self.file = IfcStore.get_file() @@ -248,7 +258,7 @@ class CreateShapeFromStepId(bpy.types.Operator): settings.set("keep-bounding-boxes", True) if self.should_include_curves: settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, element) + shape = ifcopenshell.geom.create_shape(settings, element, geometry_library=geometry_library) if shape: ifc_importer = import_ifc.IfcImporter(self.ifc_import_settings) ifc_importer.file = self.file @@ -440,9 +450,11 @@ class SelectExpressFile(bpy.types.Operator): class PurgeHdf5Cache(bpy.types.Operator): bl_idname = "bim.purge_hdf5_cache" bl_label = "Purge HDF5 Cache" + bl_description = "Clean up HDF5 cache files except the ones that currently loaded" def execute(self, context): core.purge_hdf5_cache(tool.Debug) + self.report({"INFO"}, "HDF5 cache purged.") return {"FINISHED"} @@ -457,7 +469,7 @@ class OverrideDisplayType(bpy.types.Operator): return {"FINISHED"} -class PrintUnusedElementStats(bpy.types.Operator, tool.Ifc.Operator): +class PrintUnusedElementStats(bpy.types.Operator): bl_idname = "bim.print_unused_elements_stats" bl_label = "Print Unused Elements Stats" bl_options = {"REGISTER", "UNDO"} @@ -468,9 +480,9 @@ class PrintUnusedElementStats(bpy.types.Operator, tool.Ifc.Operator): ignore_contexts: bpy.props.BoolProperty(name="Ignore Contexts", default=True) ignore_relationships: bpy.props.BoolProperty(name="Ignore Relationships", default=True) ignore_types: bpy.props.BoolProperty(name="Ignore Types", default=True) - ignore_styled_items: bpy.props.BoolProperty(name="Ignoer Styled Items", default=True) + ignore_styled_items: bpy.props.BoolProperty(name="Ignore Styled Items", default=True) - def _execute(self, context): + def execute(self, context): props = context.scene.BIMDebugProperties # ignore some classes that could have zero 0 inverse references by their nature ignore_classes = [] @@ -482,31 +494,47 @@ class PrintUnusedElementStats(bpy.types.Operator, tool.Ifc.Operator): ignore_classes += ["IfcTypeProduct"] if self.ignore_styled_items: ignore_classes += ["IfcStyledItem"] + ignore_classes += [ + "IfcIndexedColourMap", # Only referenced by inverse attributes. + "IfcIndexedTextureMap", # Only referenced by inverse attributes. + ] unused_elements = tool.Debug.print_unused_elements_stats(props.ifc_class_purge, ignore_classes) self.report({"INFO"}, f"{unused_elements} unused elements found, check the system console for the details.") + return {"FINISHED"} class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): 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 refernces and will remove them, use very carefully" + "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" + "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"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + if event.type == "LEFTMOUSE" and event.alt: + context.window_manager.fileselect_add(self) + return self.execute(context) + + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + cls.poll_message_set("No IFC file is loaded.") + return False + return True def _execute(self, context): props = context.scene.BIMDebugProperties 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.") - tool.Ifc.get().write(self.filepath) + if self.filepath: + tool.Ifc.get().write(self.filepath) return # A whitelisted class is a class that only contains simple data that @@ -520,6 +548,7 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): # IfcStructuralConnectionCondition # IfcStructuralLoad + # Keep list sorted alphabetically. whitelisted_classes = [ "IfcActorRole", "IfcAddress", @@ -547,13 +576,13 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): "IfcPresentationItem", "IfcProductDefinitionShape", "IfcPropertyAbstraction", + "IfcPropertyDefinition", # A bit of a questionable one, and the odd one out from IfcRoot. "IfcRecurrencePattern", "IfcReference", "IfcRepresentation", - "IfcRepresentationContext", + # "IfcRepresentationContext", # Can be present even in basic empty project. "IfcRepresentationItem", "IfcRepresentationMap", - "IfcPropertyDefinition", # A bit of a questionable one, and the odd one out from IfcRoot. "IfcSchedulingTime", "IfcShapeAspect", "IfcTable", @@ -606,7 +635,54 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): elif total_batches > 20: print("Finished 20 batches. Manually stopping in case of infinite loop.") self.report({"INFO"}, f"Auto purged {total_purged} orphaned elements") - tool.Ifc.get().write(self.filepath) + if self.filepath: + tool.Ifc.get().write(self.filepath) + + +class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.purge_unused_objects" + bl_label = "Purge Unused Objects" + bl_options = {"REGISTER", "UNDO"} + + object_type: bpy.props.EnumProperty( + name="Object Type", + items=( + ("TYPE", "Type", ""), + ("PROFILE", "Profile", ""), + ("STYLE", "Style", ""), + ("MATERIAL", "Material", ""), + ), + ) + + def _execute(self, context): + object_type = self.object_type + if object_type == "TYPE": + purged = bonsai.core.type.purge_unused_types(tool.Ifc, tool.Type, tool.Geometry) + elif object_type == "PROFILE": + purged = bonsai.core.profile.purge_unused_profiles(tool.Ifc, tool.Profile) + elif object_type == "STYLE": + purged = tool.Style.purge_unused_styles() + elif object_type == "MATERIAL": + purged = tool.Material.purge_unused_materials() + else: + self.report({"ERROR"}, f"Invalid object type {object_type}.") + return {"CANCELLED"} + + self.report({"INFO"}, f"{purged} unused {object_type.lower()}s were purged.") + + if purged == 0: + return + + scene = context.scene + if object_type == "PROFILE": + if scene.BIMProfileProperties.is_editing: + bpy.ops.bim.load_profiles() + elif object_type == "STYLE": + if scene.BIMStylesProperties.is_editing: + bpy.ops.bim.load_styles() + elif object_type == "MATERIAL": + if scene.BIMMaterialProperties.is_editing: + bpy.ops.bim.load_materials() class PipInstall(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index cb7bb771d5..a3578ed702 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -88,6 +88,7 @@ classes = ( operator.SelectAllDrawings, operator.SelectAssignedProduct, operator.SelectDocIfcFile, + operator.OpenDocumentationWebUi, prop.Variable, prop.Drawing, prop.Document, diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index c209b22c2f..d099ea03ba 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -468,11 +468,11 @@ class BaseDecorator: blf.rotation(font_id, ang) blf.color(font_id, *color) - # blf.enable(font_id, blf.SHADOW) - # blf.shadow(font_id, 5, 0, 0, 0, 1) - # blf.shadow_offset(font_id, 1, -1) + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) blf.draw(font_id, text) blf.disable(font_id, blf.ROTATION) + blf.disable(font_id, blf.SHADOW) def draw_dimension_text(self, context, get_text, description, dimension_data, **draw_label_kwargs): prefix = dimension_data["text_prefix"] diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 05cf85b8a5..b14bfb1d46 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -202,7 +202,9 @@ def format_distance( if not isArea: add_inches = bool(inches) or not suppress_zero_inches tx_dist = "" - if feet is not None: + if feet: + tx_dist += str(feet) + "'" + if not feet and not add_inches: tx_dist += str(feet) + "'" if feet and add_inches: tx_dist += " - " diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 72014d838c..c66816ae60 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . import os -import re import bpy import json import time @@ -43,13 +42,12 @@ import bonsai.core.drawing as core 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.module.drawing.scheduler as scheduler -import bonsai.bim.module.drawing.helper as helper import bonsai.bim.export_ifc 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 from lxml import etree +from math import radians from mathutils import Vector, Color, Matrix from timeit import default_timer as timer from bonsai.bim.module.drawing.prop import RasterStyleProperty, RASTER_STYLE_PROPERTIES_EXCLUDE @@ -204,7 +202,12 @@ class CreateDrawing(bpy.types.Operator): @classmethod def poll(cls, context): - return bool(tool.Ifc.get() and tool.Drawing.is_drawing_active()) + if not tool.Ifc.get(): + return False + if not tool.Drawing.is_drawing_active(): + cls.poll_message_set("No active drawing.") + return False + return True def invoke(self, context, event): # printing all drawings on shift+click @@ -226,7 +229,7 @@ class CreateDrawing(bpy.types.Operator): for drawing_i, drawing_id in enumerate(drawings_to_print): self.drawing_index = drawing_i if self.print_all: - bpy.ops.bim.activate_drawing(drawing=drawing_id, camera_view_point=False) + bpy.ops.bim.activate_drawing(drawing=drawing_id, should_view_from_camera=False) self.camera = context.scene.camera self.camera_element = tool.Ifc.get_entity(self.camera) @@ -252,6 +255,7 @@ class CreateDrawing(bpy.types.Operator): self.svg_writer.camera_projection = tuple( self.camera.matrix_world.to_quaternion() @ Vector((0, 0, -1)) ) + self.svg_writer.calculate_scale() self.svg_writer.setup_drawing_resource_paths(self.camera_element) @@ -264,7 +268,12 @@ class CreateDrawing(bpy.types.Operator): with profile("Generate linework"): if tool.Drawing.is_camera_orthographic(): - linework_svg = self.generate_linework(context) + if self.camera.data.BIMCameraProperties.linework_mode == "OPENCASCADE": + linework_svg = self.generate_linework(context) + elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": + linework_svg = self.generate_freestyle_linework(context) + elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": + linework_svg = self.generate_freestyle_linework(context) with profile("Generate annotation"): if tool.Drawing.is_camera_orthographic(): @@ -276,7 +285,7 @@ class CreateDrawing(bpy.types.Operator): tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().svg_command, svg_path) if self.print_all: - bpy.ops.bim.activate_drawing(drawing=original_drawing_id, camera_view_point=False) + bpy.ops.bim.activate_drawing(drawing=original_drawing_id, should_view_from_camera=False) return {"FINISHED"} def get_camera_dimensions(self): @@ -471,11 +480,35 @@ class CreateDrawing(bpy.types.Operator): geom_settings = ifcopenshell.geom.settings() geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE) + # See bug 5231 - offset no longer available in v0.8.0 + absolute_placements = set() + placement_replacements = {} if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view: - offset = ifcopenshell.ifcopenshell_wrapper.float_array_3() # A 2mm Z offset to combat Z-fighting in plan or RCPs - offset[2] = 0.002 if target_view == "PLAN_VIEW" else -0.002 - geom_settings.offset = offset + offset_z = 0.002 if target_view == "PLAN_VIEW" else -0.002 + + for product in self.file.by_type("IfcProduct"): + if not product.ObjectPlacement: + continue + absolute_placement = self.get_absolute_placement(product.ObjectPlacement) + if absolute_placement.is_a("IfcLocalPlacement"): + absolute_placements.add(absolute_placement) + + transformation = np.eye(4) + transformation[2][3] = offset_z + + # Don't use undo system in case we bork up a parent caller + for placement in absolute_placements: + old = placement.RelativePlacement + new = self.get_relative_placement( + ifc, transformation @ ifcopenshell.util.placement.get_local_placement(placement) + ) + placement.RelativePlacement = new + placement_replacements[placement] = (old, new) + + # offset = ifcopenshell.ifcopenshell_wrapper.float_array_3() + # offset[2] = 0.002 if target_view == "PLAN_VIEW" else -0.002 + # geom_settings.offset = offset geom_settings.set("context-ids", context) it = ifcopenshell.geom.iterator( geom_settings, ifc, multiprocessing.cpu_count(), include=drawing_elements @@ -487,6 +520,187 @@ class CreateDrawing(bpy.types.Operator): tree.add_element(elem) drawing_elements -= processed + for placement, oldnew in placement_replacements.items(): + old, new = oldnew + placement.RelativePlacement = old + ifcopenshell.util.element.remove_deep2(ifc, new) + placement_replacements = {} + + def get_absolute_placement(self, object_placement): + if object_placement.PlacementRelTo: + return self.get_absolute_placement(object_placement.PlacementRelTo) + return object_placement + + def get_relative_placement(self, ifc, m): + x = np.array((m[0][0], m[1][0], m[2][0])) + z = np.array((m[0][2], m[1][2], m[2][2])) + o = np.array((m[0][3], m[1][3], m[2][3])) + object_matrix = ifcopenshell.util.placement.a2p(o, z, x) + return self.create_ifc_axis_2_placement_3d( + ifc, + object_matrix[:, 3][0:3], + object_matrix[:, 2][0:3], + object_matrix[:, 0][0:3], + ) + + def create_ifc_axis_2_placement_3d(self, ifc, point, up, forward): + return self.file.createIfcAxis2Placement3D( + self.file.createIfcCartesianPoint(point.tolist()), + self.file.createIfcDirection(up.tolist()), + self.file.createIfcDirection(forward.tolist()), + ) + + def generate_bisect_linework(self, context: bpy.types.Context, root): + 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 + + for obj in context.visible_objects: + if obj.type != "MESH": + continue + if not (element := tool.Ifc.get_entity(obj)): + continue + if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera): + continue + verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera) + + 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 "" + + 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) + + def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None: + if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): + return + svg_path = self.get_svg_path(cache_type="linework") + if os.path.isfile(svg_path) and self.props.should_use_linework_cache: + return svg_path + + context.scene.render.engine = "BLENDER_WORKBENCH" + context.scene.render.use_freestyle = True + context.scene.svg_export.use_svg_export = True + + linesets = context.view_layer.freestyle_settings.linesets + if len(linesets) == 1 and linesets[0].name == "LineSet": + context.view_layer.freestyle_settings.crease_angle = radians(140) + context.view_layer.freestyle_settings.use_culling = True + lineset = linesets[0] + lineset.edge_type_negation = "EXCLUSIVE" + lineset.select_silhouette = False + lineset.select_crease = False + lineset.select_border = False + lineset.select_edge_mark = False + lineset.select_contour = False + lineset.select_external_contour = False + lineset.select_material_boundary = False + lineset.select_suggestive_contour = True + lineset.select_ridge_valley = True + + edge_mesh = bpy.data.meshes.new("Temp Merged Edges") + edge_obj = bpy.data.objects.new("Temp Merged Edges", edge_mesh) + context.scene.collection.objects.link(edge_obj) + edge_bm = bmesh.new() + + visible_object_names = {obj.name for obj in bpy.context.visible_objects} + for obj in bpy.context.view_layer.objects: + is_visible = obj.name in visible_object_names + obj.hide_render = not is_visible + if ( + is_visible + and obj.type == "MESH" + and len(obj.data.edges) + and not len(obj.data.polygons) + and not obj.name.startswith("IfcAnnotation") + ): + tmp_mesh = None + try: + tmp_mesh = obj.data.copy() + tmp_mesh.transform(obj.matrix_world) + edge_bm.from_mesh(tmp_mesh) + finally: + if tmp_mesh: + bpy.data.meshes.remove(tmp_mesh) + + ret = bmesh.ops.extrude_edge_only(edge_bm, edges=edge_bm.edges) + verts_extruded = [e for e in ret["geom"] if isinstance(e, bmesh.types.BMVert)] + + cam_z = self.camera.matrix_world.to_3x3() @ self.camera.data.view_frame(scene=None)[-1].normalized() + cam_z *= 0.001 + + for v in verts_extruded: + v.co += cam_z + + edge_bm.to_mesh(edge_mesh) + edge_bm.free() + + actual_path = svg_path[0:-4] + "0001.svg" + context.scene.render.filepath = svg_path[0:-4] + bpy.ops.render.render(write_still=False) + + os.replace(actual_path, svg_path) + + bpy.data.objects.remove(edge_obj) + bpy.data.meshes.remove(edge_mesh) + + context.scene.render.use_freestyle = False + context.scene.svg_export.use_svg_export = False + + tree = etree.parse(svg_path) + root = tree.getroot() + + freestyle_width = float(root.attrib["width"]) + freestyle_height = float(root.attrib["height"]) + svg_width = self.svg_writer.width + svg_height = self.svg_writer.height + + group = root.find(".//{http://www.w3.org/2000/svg}g") + group.attrib["class"] = "projection" + + # Resize Freestyle to our proper width / height and purge all other attributes + for path in root.findall(".//{http://www.w3.org/2000/svg}path"): + for key in path.attrib: + if key == "fill": + continue + elif key != "d": + del path.attrib[key] + continue + d = path.attrib[key] + coords = d.strip().split()[1:] + new_d = "M" + for i in range(0, len(coords), 2): + x = float(coords[i][:-1]) + y = float(coords[i + 1]) + x = x / freestyle_width * svg_width + y = y / freestyle_height * svg_height + new_d += f" {x},{y}" + path.attrib["d"] = new_d + pass + + if tool.Drawing.is_camera_orthographic(): + self.generate_bisect_linework(context, root) + self.merge_linework_and_add_metadata(root) + + with open(svg_path, "wb") as svg: + svg.write(etree.tostring(root)) + + return svg_path + def generate_linework(self, context: bpy.types.Context) -> Union[str, None]: if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): return @@ -573,10 +787,15 @@ class CreateDrawing(bpy.types.Operator): return svg_path - self.move_projection_to_bottom(root) - self.merge_linework_and_add_metadata(root) + if self.camera.data.BIMCameraProperties.cut_mode == "BISECT": + self.remove_cut_linework(root) + self.generate_bisect_linework(context, root) + self.merge_linework_and_add_metadata(root) + elif self.camera.data.BIMCameraProperties.cut_mode == "OPENCASCADE": + self.move_projection_to_bottom(root) + self.merge_linework_and_add_metadata(root) - if self.camera.data.BIMCameraProperties.calculate_shapely_surfaces: + if self.camera.data.BIMCameraProperties.fill_mode == "SHAPELY": # shapely variant group = root.find("{http://www.w3.org/2000/svg}g") nm = group.attrib["{http://www.ifcopenshell.org/ns}name"] @@ -661,7 +880,7 @@ class CreateDrawing(bpy.types.Operator): path.set("class", " ".join(list(classes))) group.insert(0, path) - if self.camera.data.BIMCameraProperties.calculate_svgfill_surfaces: + if self.camera.data.BIMCameraProperties.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results from xml.dom.minidom import parseString @@ -826,7 +1045,6 @@ class CreateDrawing(bpy.types.Operator): group = root.findall(".//{http://www.w3.org/2000/svg}g")[0] - self.svg_writer.calculate_scale() x_offset = self.svg_writer.raw_width / 2 y_offset = self.svg_writer.raw_height / 2 @@ -931,6 +1149,7 @@ class CreateDrawing(bpy.types.Operator): bm = bmesh.new() bm.from_mesh(obj.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001) for edge in bm.edges: if not edge.is_manifold: bm.free() @@ -951,6 +1170,11 @@ class CreateDrawing(bpy.types.Operator): 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(): + el.getparent().remove(el) + def merge_linework_and_add_metadata(self, root): join_criteria = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinCriteria") if join_criteria: @@ -1014,10 +1238,34 @@ class CreateDrawing(bpy.types.Operator): if results: for path in old_paths: path.getparent().remove(path) + + # polygonize_full will create polygons for everything, including + # interior "holes". As a result we do two passes. The first pass + # records polygon interior rings. The second pass uses this to + # check if the exterior ring matches an interior ring. If it does, + # it's a hole. Skip it! + + interior_hashes = set() for result in results: for geom in result.geoms: - path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") if isinstance(geom, shapely.Polygon): + for interior in geom.interiors: + # Sorted because coordinate ordering may differ, + # and frozenset because shapely sometimes emits + # duplicate coordinates. + interior_hashes.add(hash(frozenset(sorted(interior.coords)))) + elif isinstance(geom, shapely.LineString): + path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") + d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z" + path.attrib["d"] = d + + for result in results: + for geom in result.geoms: + if isinstance(geom, shapely.Polygon): + path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") + if hash(frozenset(sorted(geom.exterior.coords))) in interior_hashes: + # This is a "hole", as its exterior perfectly matches an interior. + continue d = ( "M" + " L".join([",".join([str(o) for o in co]) for co in geom.exterior.coords[0:-1]]) @@ -1029,9 +1277,7 @@ class CreateDrawing(bpy.types.Operator): + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z" ) - elif isinstance(geom, shapely.LineString): - d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z" - path.attrib["d"] = d + path.attrib["d"] = d # Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge. if not element.is_a("IfcWall") and not element.is_a("IfcSlab"): @@ -1235,11 +1481,19 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): props = context.scene.DocProperties - return props.drawings and props.sheets and context.scene.BIMProperties.data_dir + # Won't be visible in UI anyway. + if not props.sheets or not context.scene.BIMProperties.data_dir: + return False + if not tool.Drawing.get_active_drawing_item(): + cls.poll_message_set("No drawing selected.") + return False + return True def _execute(self, context): props = context.scene.DocProperties - active_drawing = props.drawings[props.active_drawing_index] + active_drawing = tool.Drawing.get_active_drawing_item() + assert active_drawing + active_sheet = tool.Drawing.get_active_sheet(context) drawing = tool.Ifc.get().by_id(active_drawing.ifc_definition_id) drawing_reference = tool.Drawing.get_drawing_document(drawing) @@ -1314,6 +1568,10 @@ class CreateSheets(bpy.types.Operator, tool.Ifc.Operator): active_sheet = props.sheets[props.active_sheet_index] sheet = tool.Ifc.get().by_id(active_sheet.ifc_definition_id) + # Update any drawing boundary changes + sheet_builder = sheeter.SheetBuilder() + sheet_builder.update_sheet_drawing_sizes(sheet) + if not sheet.is_a("IfcDocumentInformation"): return @@ -1463,7 +1721,7 @@ class ActivateModel(bpy.types.Operator): bl_idname = "bim.activate_model" bl_label = "Activate Model" bl_options = {"REGISTER", "UNDO"} - bl_description = "Activates the model view" + bl_description = "Activate the model view, hide all annotations" def execute(self, context): dprops = bpy.context.scene.DocProperties @@ -1471,10 +1729,23 @@ class ActivateModel(bpy.types.Operator): CutDecorator.uninstall() - # save current visibility statuses + # Preserve current visibility statuses for: + # - non-ifc objects + # - type product + # - annotations (so we won't unhide other drawings) + ifc_file = tool.Ifc.get() visibility_status: dict[bpy.types.Object, bool] = {} for obj in bpy.data.objects: - visibility_status[obj] = obj.hide_get() + element = tool.Ifc.get_entity(obj) + if not element: + hide = obj.hide_get() + elif element.is_a("IfcAnnotation"): + hide = True + elif element.is_a("IfcTypeProduct"): + hide = obj.hide_get() + else: + continue + visibility_status[obj] = hide if not bpy.app.background: with context.temp_override(**tool.Blender.get_viewport_context()): @@ -1515,57 +1786,57 @@ class ActivateDrawing(bpy.types.Operator): bl_description = ( "Activates the selected drawing view.\n\n" + "ALT+CLICK to keep the viewport position.\n\n" - + "SHIFT+CLICK activiate drawing without turning objects on/off." + + "SHIFT+CLICK to load a quick preview of the drawing view." ) drawing: bpy.props.IntProperty() - camera_view_point: bpy.props.BoolProperty(name="Camera View Point", default=True, options={"SKIP_SAVE"}) - switch_camera_only: bpy.props.BoolProperty(name="Only Changes Camera View", default=False, options={"SKIP_SAVE"}) + should_view_from_camera: bpy.props.BoolProperty(name="Should View From Camera", default=True, options={"SKIP_SAVE"}) + use_quick_preview: bpy.props.BoolProperty(name="Use Quick Preview", default=False, options={"SKIP_SAVE"}) def invoke(self, context, event): - # keep the viewport position on alt+click - # make sure to use SKIP_SAVE on property, otherwise it might get stuck if event.type == "LEFTMOUSE" and event.alt: - self.camera_view_point = False - # Only activates the camera view on shift+click. Does not turn on/off objects in scene + self.should_view_from_camera = False if event.type == "LEFTMOUSE" and event.shift: - self.switch_camera_only = True + self.use_quick_preview = True return self.execute(context) def execute(self, context): if bpy.context.scene.DocProperties.is_editing_drawings == False: bpy.ops.bim.load_drawings() + drawing = tool.Ifc.get().by_id(self.drawing) dprops = bpy.context.scene.DocProperties - if self.switch_camera_only: - camera = tool.Drawing.import_drawing(drawing) - tool.Blender.activate_camera(camera) - else: - if not self.camera_view_point: - viewport_position = tool.Blender.get_viewport_position() + if self.use_quick_preview: + tool.Blender.activate_camera(tool.Drawing.import_temporary_drawing_camera(drawing)) + return {"FINISHED"} - core.activate_drawing_view(tool.Ifc, tool.Blender, tool.Drawing, drawing=drawing) + if not self.should_view_from_camera: + viewport_position = tool.Blender.get_viewport_position() - if not self.camera_view_point: - tool.Blender.set_viewport_position(viewport_position) + core.activate_drawing_view(tool.Ifc, tool.Blender, tool.Drawing, drawing=drawing) - dprops.active_drawing_id = self.drawing - # reset DrawingsData to reload_drawing_styles work correctly - DrawingsData.is_loaded = False - dprops.drawing_styles.clear() - if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"): - bpy.ops.bim.reload_drawing_styles() - bpy.ops.bim.activate_drawing_style() + if not self.should_view_from_camera: + tool.Blender.set_viewport_position(viewport_position) + + dprops.active_drawing_id = self.drawing + # reset DrawingsData to reload_drawing_styles work correctly + DrawingsData.is_loaded = False + dprops.drawing_styles.clear() + if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"): + bpy.ops.bim.reload_drawing_styles() + bpy.ops.bim.activate_drawing_style() + + if tool.Drawing.is_camera_orthographic(): core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) - CutDecorator.install(context) - tool.Drawing.show_decorations() + CutDecorator.install(context) + tool.Drawing.show_decorations() - # Save drawing bounds to the .ifc file - camera = context.scene.camera - camera_props = camera.data.BIMCameraProperties - if camera_props.update_representation(camera): - bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") + # Save drawing bounds to the .ifc file + camera = context.scene.camera + camera_props = camera.data.BIMCameraProperties + if camera_props.update_representation(camera): + bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") return {"FINISHED"} @@ -1698,7 +1969,10 @@ class ReloadDrawingStyles(bpy.types.Operator): drawing_style.raster_style = json.dumps(style_data["raster_style"]) if current_style is not None: - camera_props.active_drawing_style_index = styles.index(current_style) + try: + camera_props.active_drawing_style_index = styles.index(current_style) + except ValueError: + self.report({"INFO"}, f"Could not find style {current_style} in EPset_Drawing.ShadingStyles.") return {"FINISHED"} @@ -1779,7 +2053,7 @@ class SaveDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): if self.index: index = int(self.index) else: - index = context.active_object.data.BIMCameraProperties.active_drawing_style_index + index = context.scene.camera.data.BIMCameraProperties.active_drawing_style_index scene.DocProperties.drawing_styles[index].raster_style = json.dumps(style) bpy.ops.bim.save_drawing_styles_data() @@ -1875,7 +2149,7 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} - def set_raster_style(self, context): + def set_raster_style(self, context: bpy.types.Context) -> None: scene = context.scene # Do not remove. It is used in exec later space = self.get_view_3d(context) # Do not remove. It is used in exec later style = json.loads(self.drawing_style.raster_style) @@ -1889,7 +2163,7 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): # Differences in Blender versions mean result in failures here print(f"Failed to set shading style {path} to {value}") - def set_query(self, context): + 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: @@ -1908,7 +2182,7 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): if self.drawing_style.exclude_query: self.parse_filter_query("EXCLUDE", context) - def parse_filter_query(self, mode, context): + def parse_filter_query(self, mode: Literal["INCLUDE", "EXCLUDE"], context: bpy.types.Context) -> None: if mode == "INCLUDE": objects = context.scene.objects elif mode == "EXCLUDE": @@ -1927,7 +2201,7 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): if global_id in self.exclude_global_ids: obj.hide_viewport = True # Note: this breaks alt-H - def get_view_3d(self, context): + def get_view_3d(self, context: bpy.types.Context) -> bpy.types.Space: for area in context.screen.areas: if area.type != "VIEW_3D": continue @@ -1935,6 +2209,7 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): if space.type != "VIEW_3D": continue return space + assert False, "Space is not found." class RemoveSheet(bpy.types.Operator, tool.Ifc.Operator): @@ -2678,8 +2953,9 @@ class EditElementFilter(bpy.types.Operator, tool.Ifc.Operator): filter_mode: bpy.props.StringProperty() def _execute(self, context): - props = context.active_object.data.BIMCameraProperties obj = bpy.context.scene.camera + assert obj + props = obj.data.BIMCameraProperties element = tool.Ifc.get_entity(obj) pset = tool.Pset.get_element_pset(element, "EPset_Drawing") if self.filter_mode == "INCLUDE": @@ -2689,7 +2965,7 @@ class EditElementFilter(bpy.types.Operator, tool.Ifc.Operator): query = tool.Search.export_filter_query(props.exclude_filter_groups) or None ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=pset, properties={"Exclude": query}) obj.data.BIMCameraProperties.filter_mode = "NONE" - bpy.ops.bim.activate_drawing(drawing=element.id(), camera_view_point=False) + bpy.ops.bim.activate_drawing(drawing=element.id(), should_view_from_camera=False) class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator): @@ -2857,3 +3133,16 @@ class ConvertSVGToDXF(bpy.types.Operator): self.report({"INFO"}, f"{len(drawing_uris)} drawings were converted to .dxf.") return {"FINISHED"} + + +class OpenDocumentationWebUi(bpy.types.Operator): + bl_idname = "bim.open_documentation_web_ui" + bl_label = "Open Documentation Web UI" + bl_description = "Open the documentation web UI page" + + def execute(self, context): + if not context.scene.WebProperties.is_connected: + bpy.ops.bim.connect_websocket_server(page="documentation") + else: + bpy.ops.bim.open_web_browser(page="documentation") + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index b0190e0165..e67c8a908f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -259,8 +259,12 @@ def update_should_draw_decorations(self, context): continue tool.Drawing.update_text_value(obj) refresh_drawing_data() + if bpy.app.background: + return decoration.DecorationsHandler.install(context) else: + if bpy.app.background: + return decoration.DecorationsHandler.uninstall() @@ -386,8 +390,31 @@ class DocProperties(PropertyGroup): class BIMCameraProperties(PropertyGroup): - calculate_shapely_surfaces: BoolProperty(name="Calculate Shapely Surfaces", default=False) - calculate_svgfill_surfaces: BoolProperty(name="Calculate SVGFill Surfaces", default=False) + linework_mode: EnumProperty( + items=[ + ("OPENCASCADE", "OpenCASCADE", "Slower, more accurate, with more features"), + ("FREESTYLE", "Freestyle", "Faster, less accurate, no fill support"), + ], + default="OPENCASCADE", + name="Linework Mode", + ) + fill_mode: EnumProperty( + items=[ + ("NONE", "None", "Disable filling areas seen in projection"), + ("SHAPELY", "Shapely", "Recommended"), + ("SVGFILL", "SVGFill", "Experimental"), + ], + default="NONE", + name="Fill Mode", + ) + cut_mode: EnumProperty( + items=[ + ("BISECT", "Bisect", "Faster, more forgiving to bad geometry"), + ("OPENCASCADE", "OpenCASCADE", "More technically correct"), + ], + default="BISECT", + name="Cut Mode", + ) has_underlay: BoolProperty(name="Underlay", default=False, update=update_has_underlay) has_linework: BoolProperty(name="Linework", default=True, update=update_has_linework) has_annotation: BoolProperty(name="Annotation", default=True, update=update_has_annotation) @@ -531,9 +558,10 @@ def get_relating_type_id(self, context): def update_annotation_object_type(self, context): - self.relating_type_id = "0" - # changing enum doesn't trigger refresh by itself + # Refresh enum items before changing property, + # otherwise it might map to the wrong item. AnnotationData.is_loaded = False + self.relating_type_id = "0" def update_sheet_data(self, context): diff --git a/src/bonsai/bonsai/bim/module/drawing/scheduler.py b/src/bonsai/bonsai/bim/module/drawing/scheduler.py index 2e7bc9034c..568c3ca294 100644 --- a/src/bonsai/bonsai/bim/module/drawing/scheduler.py +++ b/src/bonsai/bonsai/bim/module/drawing/scheduler.py @@ -81,11 +81,11 @@ class Scheduler: with open(stylesheet_path, "r") as stylesheet: css = stylesheet.read() - matches = re.search("--font-size-pt:\s*([0-9.]+);", css) + matches = re.search(r"--font-size-pt:\s*([0-9.]+);", css) self.font_size_pt = float(matches.groups()[0]) if matches else 12 # Default to 12pt - matches = re.search("--font-size-px:\s*([0-9.]+);", css) + matches = re.search(r"--font-size-px:\s*([0-9.]+);", css) self.font_size_px = float(matches.groups()[0]) if matches else 4.13 # Magic number 4.13px ~= 12pt - matches = re.search("--font-width:\s*([0-9.]+);", css) + matches = re.search(r"--font-width:\s*([0-9.]+);", css) self.font_width = float(matches.groups()[0]) if matches else 0.45 # A magic number for OpenGost self.svg.defs.add(self.svg.style(css)) @@ -555,6 +555,8 @@ class Scheduler: if text_color: text_params["fill"] = text_color + text_params["class_"] = "schedule" + if len(text_lines) == 1 and not wrap_text: text_params.update(box_alignment_params) text_tag = self.svg.text(text_lines[0], insert=(x, y), **(text_params)) diff --git a/src/bonsai/bonsai/bim/module/drawing/sheeter.py b/src/bonsai/bonsai/bim/module/drawing/sheeter.py index 585ff38b10..ce6bee5898 100644 --- a/src/bonsai/bonsai/bim/module/drawing/sheeter.py +++ b/src/bonsai/bonsai/bim/module/drawing/sheeter.py @@ -153,26 +153,29 @@ class SheetBuilder: drawing_path = tool.Drawing.get_document_uri(tool.Drawing.get_drawing_reference(drawing)) drawing_tree = ET.parse(drawing_path) drawing_root = drawing_tree.getroot() - view_width = self.convert_to_mm(drawing_root.attrib.get("width")) - view_height = self.convert_to_mm(drawing_root.attrib.get("height")) + view_width = round(self.convert_to_mm(drawing_root.attrib.get("width")), 2) + view_height = round(self.convert_to_mm(drawing_root.attrib.get("height")), 2) foreground = drawing_view.find(f'.//{SVG}image[@data-type="foreground"]') - height = float(foreground.attrib["height"]) - width = float(foreground.attrib["width"]) - readjust = Vector((width - view_width, height - view_height)) / 2 + current_width = round(float(foreground.attrib["width"]), 2) + current_height = round(float(foreground.attrib["height"]), 2) - for image in drawing_view.findall(f"{SVG}image"): - x = float(image.attrib["x"]) - y = float(image.attrib["y"]) - if image.attrib["data-type"] == "view-title": - image.attrib["x"] = str(x + readjust.x) - # negate y offset because view-title comes AFTER foreground - image.attrib["y"] = str(y - readjust.y) - else: - image.attrib["x"] = str(x + readjust.x) - image.attrib["y"] = str(y + readjust.y) - image.attrib["width"] = str(view_width) - image.attrib["height"] = str(view_height) + # Check if the dimensions have changed + if current_width != view_width or current_height != view_height: + readjust = Vector((current_width - view_width, current_height - view_height)) / 2 + + for image in drawing_view.findall(f"{SVG}image"): + x = float(image.attrib["x"]) + y = float(image.attrib["y"]) + if image.attrib["data-type"] == "view-title": + image.attrib["x"] = str(x + readjust.x) + # negate y offset because view-title comes AFTER foreground + image.attrib["y"] = str(y - readjust.y) + else: + image.attrib["x"] = str(x + readjust.x) + image.attrib["y"] = str(y + readjust.y) + image.attrib["width"] = str(view_width) + image.attrib["height"] = str(view_height) layout_tree.write(layout_path) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 47a6f67b70..7f7651ea5b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -64,9 +64,15 @@ class BIM_PT_camera(Panel): row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH") row = self.layout.row() - row.prop(props, "calculate_shapely_surfaces") - row = self.layout.row() - row.prop(props, "calculate_svgfill_surfaces") + row.prop(props, "linework_mode") + if props.linework_mode == "OPENCASCADE": + row = self.layout.row() + row.prop(props, "fill_mode") + row = self.layout.row() + row.prop(props, "cut_mode") + elif not hasattr(context.scene, "svg_export"): + row = self.layout.row() + row.label(text="Freestyle SVG Exporter Not Installed", icon="ERROR") row = self.layout.row() row.prop(props, "width") @@ -106,11 +112,7 @@ class BIM_PT_element_filters(Panel): @classmethod def poll(cls, context): - return ( - context.scene.camera - and context.active_object - and hasattr(context.active_object.data, "BIMCameraProperties") - ) + return bool((camera := context.scene.camera) and tool.Ifc.get_entity(camera)) def draw(self, context): if not ElementFiltersData.is_loaded: @@ -160,17 +162,15 @@ class BIM_PT_drawing_underlay(Panel): @classmethod def poll(cls, context): - return ( - context.scene.camera - and context.active_object - and hasattr(context.active_object.data, "BIMCameraProperties") - ) + return bool((camera := context.scene.camera) and tool.Ifc.get_entity(camera)) def draw(self, context): layout = self.layout layout.use_property_split = True + camera = context.scene.camera + assert camera dprops = context.scene.DocProperties - props = context.active_object.data.BIMCameraProperties + props = camera.data.BIMCameraProperties drawing_index_is_valid = props.active_drawing_style_index < len(dprops.drawing_styles) if not DrawingsData.is_loaded: @@ -289,6 +289,9 @@ class BIM_PT_drawings(Panel): create_drawing_button = row.row(align=True) create_drawing_button.operator("bim.create_drawing", text="", icon="OUTPUT") create_drawing_button.enabled = active_drawing.ifc_definition_id > 0 + + # might need a different icon since the URL icon is already used by the open_drawing + row.operator("bim.open_documentation_web_ui", icon="URL", text="") self.layout.template_list( "BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index" ) diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index ac4e05954f..b10fe4c1a6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -274,6 +274,10 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return operator.description or "" def _execute(self, context): + if not (camera := context.scene.camera) or not tool.Ifc.get_entity(camera): + self.report({"ERROR"}, "No drawing active for annotation hotkeys.") + return {"CANCELLED"} + self.props = context.scene.BIMAnnotationProperties getattr(self, f"hotkey_{self.hotkey}")() diff --git a/src/bonsai/bonsai/bim/module/fm/operator.py b/src/bonsai/bonsai/bim/module/fm/operator.py index cd9a8559f0..a7c4906325 100644 --- a/src/bonsai/bonsai/bim/module/fm/operator.py +++ b/src/bonsai/bonsai/bim/module/fm/operator.py @@ -36,7 +36,10 @@ class ExecuteIfcFM(bpy.types.Operator): @classmethod def poll(cls, context): props = context.scene.BIMFMProperties - return props.should_load_from_memory or props.ifc_files.single_file + if not props.should_load_from_memory and not props.ifc_files.single_file: + cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.") + return False + return True def invoke(self, context, event): props = context.scene.BIMFMProperties diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index f07aa0b19f..a79476d967 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -20,7 +20,9 @@ import bpy from . import ui, prop, operator classes = ( + operator.AddMeshlikeItem, operator.AddRepresentation, + operator.AddSweptAreaSolidItem, operator.CopyRepresentation, operator.DisableEditingRepresentationItemShapeAspect, operator.DisableEditingRepresentationItemStyle, @@ -36,6 +38,7 @@ classes = ( operator.EnableEditingRepresentationItems, operator.FlipObject, operator.GetRepresentationIfcParameters, + operator.ImportRepresentationItems, operator.OverrideDelete, operator.OverrideDuplicateMove, operator.OverrideDuplicateMoveLinked, @@ -57,9 +60,11 @@ classes = ( operator.SelectConnection, operator.SwitchRepresentation, operator.UnassignRepresentationItemStyle, + operator.UpdateItemAttributes, operator.UpdateParametricRepresentation, operator.UpdateRepresentation, prop.RepresentationItem, + prop.RepresentationItemObject, prop.ShapeAspect, prop.BIMObjectGeometryProperties, prop.BIMGeometryProperties, diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index 2f0c68f770..a7f6c718fe 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -46,15 +46,53 @@ class ViewportData: @classmethod def mode(cls): + obj_mode = ("OBJECT", "IFC Object Mode", "View and move the placements of objects", "OBJECT_DATAMODE", 0) + item_mode = ("ITEM", "IFC Item Mode", "View individual representation items", "MESH_DATA", 1) + edit_mode = ("EDIT", "IFC Edit Mode", "Edit representation items", "EDITMODE_HLT", 2) + obj = bpy.context.active_object - obj_mode = [("OBJECT", "IFC Object Mode", "", "OBJECT_DATAMODE", 0)] - mesh_modes = [ - ("OBJECT", "IFC Object Mode", "", "OBJECT_DATAMODE", 0), - ("EDIT", "IFC Edit Mode", "", "EDITMODE_HLT", 1), - ] - if not obj or not tool.Blender.is_editable(obj): - return obj_mode - return mesh_modes + element = tool.Ifc.get_entity(obj) + + modes = [obj_mode] + + if bpy.context.scene.BIMGeometryProperties.representation_obj: + modes.append(item_mode) + + if not obj: + return modes + + if obj in bpy.context.scene.BIMProjectProperties.clipping_planes_objs: + pass + elif element: + if (usage := tool.Model.get_usage_type(element)) and usage in ("LAYER1", "LAYER2"): + pass + elif tool.Geometry.is_locked(element): + pass + elif usage == "PROFILE": + modes.append(edit_mode) + elif usage == "LAYER3": + modes.append(edit_mode) + elif obj.data and tool.Geometry.is_profile_based(obj.data): + modes.append(edit_mode) + elif element.is_a("IfcRelSpaceBoundary"): + modes.append(edit_mode) + elif element.is_a("IfcGridAxis"): + modes.append(edit_mode) + elif tool.Blender.Modifier.is_editing_parameters(obj): + # This should go BEFORE the modifiers + pass + elif tool.Blender.Modifier.is_roof(element): + modes.append(edit_mode) + elif tool.Blender.Modifier.is_railing(element): + modes.append(edit_mode) + elif item_mode not in modes: + modes.append(item_mode) + elif tool.Geometry.is_representation_item(obj): + modes.append(edit_mode) + else: # A regular Blender object + modes.append(edit_mode) + + return modes class RepresentationsData: @@ -161,7 +199,8 @@ class RepresentationItemsData: assert obj element = tool.Geometry.get_active_representation(obj) if element: - if not element.is_a("IfcShapeRepresentation"): + # IfcShapeRepresentation or IfcTopologyRepresentation. + if not element.is_a("IfcShapeModel"): return 0 queue = list(element.Items) while queue: diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py new file mode 100644 index 0000000000..866a53ec92 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -0,0 +1,160 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 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 blf +import gpu +import bmesh +import bonsai.tool as tool +from bpy.types import SpaceView3D +from mathutils import Vector +from gpu_extras.batch import batch_for_shader +from bpy_extras.view3d_utils import location_3d_to_region_2d + + +class ItemDecorator: + 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_text, (context,), "WINDOW", "POST_PIXEL")) + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (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_text(self, context): + self.addon_prefs = tool.Blender.get_addon_preferences() + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + font_id = 0 + blf.size(font_id, 12) + blf.enable(font_id, blf.SHADOW) + color = selected_elements_color + blf.color(font_id, *color) + + for item in context.scene.BIMGeometryProperties.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) + tag = obj.name.split("/")[1] + coords_2d = location_3d_to_region_2d(context.region, context.region_data, centroid) + if coords_2d: + w, h = blf.dimensions(font_id, tag) + coords_2d -= Vector((w * 0.5, h * 0.5)) + blf.position(font_id, coords_2d[0], coords_2d[1], 0) + blf.draw(font_id, tag) + blf.disable(font_id, blf.SHADOW) + + def draw(self, context): + def transparent_color(color, alpha=0.3): + color = [i for i in color] + color[3] = alpha + return color + + self.addon_prefs = tool.Blender.get_addon_preferences() + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + + self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + self.line_shader.bind() # required to be able to change uniforms of the shader + # POLYLINE_UNIFORM_COLOR specific uniforms + self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + self.line_shader.uniform_float("lineWidth", 2.0) + + # general shader + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + + selected_verts = [] + selected_edges = [] + selected_tris = [] + unselected_verts = [] + unselected_edges = [] + unselected_tris = [] + for item in context.scene.BIMGeometryProperties.item_objs: + if (obj := item.obj) and obj.hide_get() == False: + if obj.select_get(): + if context.mode != "OBJECT": + continue + edges = selected_edges + verts = selected_verts + tris = selected_tris + else: + edges = unselected_edges + verts = unselected_verts + tris = unselected_tris + + i = len(verts) + matrix_world = obj.matrix_world + bbox_verts = [matrix_world @ Vector(co) for co in obj.bound_box] + bbox_edges = [ + (0 + i, 3 + i), + (3 + i, 7 + i), + (7 + i, 4 + i), + (4 + i, 0 + i), + (0 + i, 1 + i), + (3 + i, 2 + i), + (7 + i, 6 + i), + (4 + i, 5 + i), + (1 + i, 2 + i), + (2 + i, 6 + i), + (6 + i, 5 + i), + (5 + i, 1 + i), + ] + edges.extend(bbox_edges) + verts.extend(bbox_verts) + + if (total_tris := len(obj.data.loop_triangles)) == 0: + continue + + if total_tris > 1000: # For performance + continue + + offset = len(verts) + verts.extend([tuple(obj.matrix_world @ v.co) for v in obj.data.vertices]) + tris.extend([tuple([i + offset for i in t.vertices]) for t in obj.data.loop_triangles]) + + if unselected_verts: + self.draw_batch("LINES", unselected_verts, transparent_color(unselected_elements_color), unselected_edges) + self.draw_batch("TRIS", unselected_verts, transparent_color(special_elements_color), unselected_tris) + + if selected_verts: + self.draw_batch("LINES", selected_verts, selected_elements_color, selected_edges) + self.draw_batch("TRIS", selected_verts, transparent_color(selected_elements_color), selected_tris) diff --git a/src/bonsai/bonsai/bim/module/geometry/helper.py b/src/bonsai/bonsai/bim/module/geometry/helper.py index 718cd88039..6a0d2e189b 100644 --- a/src/bonsai/bonsai/bim/module/geometry/helper.py +++ b/src/bonsai/bonsai/bim/module/geometry/helper.py @@ -18,9 +18,12 @@ import bpy import bmesh +import shapely import mathutils +import numpy as np import ifcopenshell import ifcopenshell.util.unit +import ifcopenshell.util.shape import bonsai.tool as tool from math import pi, pow from mathutils import Vector, Matrix, geometry @@ -117,6 +120,218 @@ class Helper: return {"profile": profile, "extrusion": extrusion} + def auto_detect_profiles( + self, obj: bpy.types.Object, mesh: bpy.types.Mesh, position: Matrix | None = None + ) -> Union[tuple, dict]: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + if position is None: + position = Matrix() + position_i = position.inverted() + + groups = {"IFCARCINDEX": [], "IFCCIRCLE": []} + for i, group in enumerate(obj.vertex_groups): + if "IFCARCINDEX" in group.name: + groups["IFCARCINDEX"].append(i) + elif "IFCCIRCLE" in group.name: + groups["IFCCIRCLE"].append(i) + + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") + + # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess + # This is how we access vertex groups via bmesh, apparently, it's not very intuitive + deform_layer = bm.verts.layers.deform.active + + # Sanity check + group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} + for vert in bm.verts: + total_groups = 0 + is_circle = False + for group_type, group_indices in groups.items(): + if not group_indices: + continue + is_special, group_index = tool.Blender.bmesh_check_vertex_in_groups(vert, deform_layer, group_indices) + if not is_special: + continue + if group_type == "IFCCIRCLE": + is_circle = True + group_verts[group_type].setdefault(group_index, 0) + group_verts[group_type][group_index] += 1 + total_groups += 0 + if total_groups > 1: # A vert can only belong to one group + return (False, "AMBIGUOUS_SPECIAL_VERTEX") + elif is_circle: + pass # Circles are allowed to be unclosed + elif total_groups == 0 and len(vert.link_edges) != 2: # Unclosed loop or forked loop + return (False, "UNCLOSED_LOOP") + + for group_type, group_counts in group_verts.items(): + if group_type == "IFCARCINDEX": + for group_count in group_counts.values(): + if group_count != 3: # Each arc needs 3 verts + return (False, "3POINT_ARC") + elif group_type == "IFCCIRCLE": + for group_count in group_counts.values(): + if group_count != 2: # Each circle needs 2 verts + return (False, "CIRCLE") + + loop_edges = set(bm.edges) + + # Create loops from edges + loops = [] + while loop_edges: + edge = loop_edges.pop() + loop = [edge] + has_found_connected_edge = True + while has_found_connected_edge: + has_found_connected_edge = False + for edge in loop_edges.copy(): + edge_verts = set(edge.verts) + if edge_verts & set(loop[0].verts): + loop.insert(0, edge) + loop_edges.remove(edge) + has_found_connected_edge = True + elif edge_verts & set(loop[-1].verts): + loop.append(edge) + loop_edges.remove(edge) + has_found_connected_edge = True + loops.append(loop) + + tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) + + def is_in_group(v, group_name): + 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): + for group_index in groups[group_name]: + if group_index in v[deform_layer]: + return group_index + + # Convert all loops into IFC curves + curves = [] + for loop in loops: + + if len(loop) == 1 and all([is_in_group(v, "IFCCIRCLE") for v in loop[0].verts]): + v1, v2 = loop[0].verts + mid = v1.co.lerp(v2.co, 0.5) + mid = (position_i @ (mid / unit_scale)).to_2d() + v1 = (position_i @ (v1.co / unit_scale)).to_2d() + radius = (mid - v1).length + curves.append( + tmp.createIfcCircle(tmp.createIfcAxis2Placement2D(tmp.createIfcCartesianPoint(list(mid))), radius) + ) + else: # For now, assume closed loop + loop_verts = [] + for i, edge in enumerate(loop): + if i == 0: + if edge.verts[0] in loop[i + 1].verts: + loop_verts.append(edge.verts[1]) + loop_verts.append(edge.verts[0]) + elif edge.verts[1] in loop[i + 1].verts: + loop_verts.append(edge.verts[0]) + loop_verts.append(edge.verts[1]) + else: + loop_verts.append(edge.other_vert(loop_verts[-1])) + loop_verts.pop() + + # Handle loop_verts possibly starting halfway through an arc + if (group_index := get_group_index(loop_verts[0], "IFCARCINDEX")) is not None: + if get_group_index(loop_verts[1], "IFCARCINDEX") != group_index: + loop_verts.insert(0, loop_verts.pop()) + loop_verts.insert(0, loop_verts.pop()) + elif get_group_index(loop_verts[2], "IFCARCINDEX") != group_index: + loop_verts.insert(0, loop_verts.pop()) + + if tmp.schema != "IFC2X3" and any([is_in_group(v, "IFCARCINDEX") for v in loop_verts]): + # We need to specify segments + coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] + points = tmp.createIfcCartesianPointList2D(coord_list) + i = 0 + segments = [] + total_verts = len(loop_verts) + while i < total_verts: + v = loop_verts[i] + if ( + i + 1 != total_verts + and is_in_group(v, "IFCARCINDEX") + and is_in_group(loop_verts[i + 1], "IFCARCINDEX") + ): + segments.append(tmp.createIfcArcIndex([i + 1, i + 2, i + 3])) + i += 2 + else: + segments.append(tmp.createIfcLineIndex([i + 1, i + 2])) + i += 1 + # Close the loop + last_segment_indices = list(segments[-1][0]) + last_segment_indices[-1] = 1 + segments[-1][0] = last_segment_indices + curves.append(tmp.createIfcIndexedPolyCurve(points, segments)) + elif tmp.schema == "IFC2X3": + points = [ + tmp.createIfcCartesianPoint(list((position_i @ (v.co / unit_scale)).to_2d())) + for v in loop_verts + ] + points.append(points[0]) + curves.append(tmp.createIfcPolyline(points)) + else: # Pure straight polyline, no segments required + coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] + coord_list.append(coord_list[0]) + points = tmp.createIfcCartesianPointList2D(coord_list) + curves.append(tmp.createIfcIndexedPolyCurve(points)) + + # Sort IFC curves into either closed, or closed with void profile defs + profile_defs = [] + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + + # First convert to Shapely + polygons = {} + for curve in curves: + geometry = ifcopenshell.geom.create_shape(settings, curve) + v = ifcopenshell.util.shape.get_vertices(geometry, is_2d=True) + v = np.round(v, 4) # Round to nearest 0.1mm, otherwise things like circles don't polygonise reliably + edges = ifcopenshell.util.shape.get_edges(geometry) + boundary_lines = [shapely.LineString([v[e[0]], v[e[1]]]) for e in edges] + unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) + closed_polygons = shapely.polygonize(unioned_boundaries.geoms) + for polygon in closed_polygons.geoms: + polygons[curve] = polygon + break + + # Check for contains properly (IFC doesn't allow common boundary points) + outer_inner = {} + inner_outer = {} + for curve, polygon in polygons.items(): + for curve2, polygon2 in polygons.items(): + if curve == curve2: + continue + if polygon.contains_properly(polygon2): + outer_inner.setdefault(curve, []).append(curve2) + inner_outer.setdefault(curve2, []).append(curve) + + # Odd-even rule for nested curves + nested_level = {c: len(inner_outer[c]) if c in inner_outer else 0 for c in curves} + for curve in sorted(curves, key=lambda c: nested_level[c]): + level = nested_level[curve] + if level % 2 == 0: + if curve in outer_inner: + inners = [c for c in outer_inner[curve] if nested_level[c] == level + 1] + profile_defs.append(tmp.createIfcArbitraryProfileDefWithVoids("AREA", None, curve, inners)) + else: + profile_defs.append(tmp.createIfcArbitraryClosedProfileDef("AREA", None, curve)) + + if len(profile_defs) == 1: + profile_def = profile_defs[0] + else: + profile_def = tmp.createIfcCompositeProfileDef("AREA", None, profile_defs) + return {"ifc_file": tmp, "profile_def": profile_def} + def auto_detect_arbitrary_profile_with_voids( self, obj: bpy.types.Object, mesh: bpy.types.Mesh ) -> Union[tuple, dict]: diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 12d0844017..2547a8058b 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -16,18 +16,19 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import re import bpy import bmesh import logging import numpy as np -import json -import re +import numpy.typing as npt import ifcopenshell import ifcopenshell.util.unit import ifcopenshell.util.element import ifcopenshell.util.representation import ifcopenshell.util.placement import ifcopenshell.api +import ifcopenshell.api.grid import bonsai.core.geometry import bonsai.core.geometry as core import bonsai.core.aggregate @@ -41,6 +42,7 @@ from time import time from bonsai.bim.ifc import IfcStore from ifcopenshell.util.shape_builder import ShapeBuilder from typing import Any +from bonsai.bim.module.model.decorator import ProfileDecorator class EditObjectPlacement(bpy.types.Operator, tool.Ifc.Operator): @@ -97,10 +99,19 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_origin_set" + blender_op = bpy.ops.object.origin_set.get_rna_type() bl_label = "IFC Origin Set" + bl_description = ( + blender_op.description + ".\nAlso makes sure changes are in sync with IFC (opeartor works only on IFC objects)" + ) bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() - origin_type: bpy.props.StringProperty() + blender_type_prop = blender_op.properties["type"] + origin_type: bpy.props.EnumProperty( + name=blender_type_prop.name, + default=blender_type_prop.default, + items=[(i.identifier, i.name, i.description) for i in blender_type_prop.enum_items], + ) def _execute(self, context): objs = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects @@ -175,24 +186,23 @@ class AddRepresentation(bpy.types.Operator, tool.Ifc.Operator): ) return {"FINISH"} - # NOTE: `data` is temporary data generated to add a representation. - data: bpy.types.Mesh + new_rep_data: bpy.types.Mesh | None = None if conversion_method == "OUTLINE": if ifc_context.ContextType == "Plan": - data = tool.Geometry.generate_outline_mesh(obj, axis="+Z") + new_rep_data = tool.Geometry.generate_outline_mesh(obj, axis="+Z") elif ifc_context.ContextIdentifier == "Profile": - data = tool.Geometry.generate_outline_mesh(obj, axis="-Y") + new_rep_data = tool.Geometry.generate_outline_mesh(obj, axis="-Y") else: - data = tool.Geometry.generate_outline_mesh(obj, axis="+Z") - tool.Geometry.change_object_data(obj, data, is_global=True) + new_rep_data = tool.Geometry.generate_outline_mesh(obj, axis="+Z") + tool.Geometry.change_object_data(obj, new_rep_data, is_global=True) elif conversion_method == "BOX": if ifc_context.ContextType == "Plan": - data = tool.Geometry.generate_2d_box_mesh(obj, axis="Z") + new_rep_data = tool.Geometry.generate_2d_box_mesh(obj, axis="Z") elif ifc_context.ContextIdentifier == "Profile": - data = tool.Geometry.generate_2d_box_mesh(obj, axis="Y") + new_rep_data = tool.Geometry.generate_2d_box_mesh(obj, axis="Y") else: - data = tool.Geometry.generate_3d_box_mesh(obj) - tool.Geometry.change_object_data(obj, data, is_global=True) + new_rep_data = tool.Geometry.generate_3d_box_mesh(obj) + tool.Geometry.change_object_data(obj, new_rep_data, is_global=True) elif conversion_method in ("OBJECT", "CUBE"): if conversion_method == "OBJECT": if not (source_obj := props.representation_from_object): @@ -201,17 +211,17 @@ class AddRepresentation(bpy.types.Operator, tool.Ifc.Operator): depsgraph = context.evaluated_depsgraph_get() eval_obj = source_obj.evaluated_get(depsgraph) - data = bpy.data.meshes.new_from_object(eval_obj) + new_rep_data = bpy.data.meshes.new_from_object(eval_obj) else: # CUBE - data = bpy.data.meshes.new("Cube") - bm = tool.Blender.get_bmesh_for_mesh(data) + new_rep_data = bpy.data.meshes.new("Cube") + bm = tool.Blender.get_bmesh_for_mesh(new_rep_data) bmesh.ops.create_cube(bm, size=1) - tool.Blender.apply_bmesh(data, bm) + tool.Blender.apply_bmesh(new_rep_data, bm) if original_data: - tool.Geometry.change_object_data(obj, data, is_global=True) + tool.Geometry.change_object_data(obj, new_rep_data, is_global=True) else: - obj = tool.Geometry.recreate_object_with_data(obj, data, is_global=True) + obj = tool.Geometry.recreate_object_with_data(obj, new_rep_data, is_global=True) try: core.add_representation( @@ -227,11 +237,13 @@ class AddRepresentation(bpy.types.Operator, tool.Ifc.Operator): # Object might be recreated, need to set it as active again. if context.active_object != obj: tool.Blender.set_active_object(obj) - bpy.data.meshes.remove(data) + if new_rep_data: + bpy.data.meshes.remove(new_rep_data) except core.IncompatibleRepresentationError: if obj.data != original_data: tool.Geometry.change_object_data(obj, original_data, is_global=True) - bpy.data.meshes.remove(data) + if new_rep_data: + bpy.data.meshes.remove(new_rep_data) self.report({"ERROR"}, "No compatible representation for the context could be created.") return {"CANCELLED"} @@ -278,41 +290,28 @@ class SwitchRepresentation(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - if context.active_object.mode == "OBJECT": + if (obj := context.active_object) and obj.mode == "OBJECT": return True cls.poll_message_set("Only available in OBJECT mode - Press TAB in the viewport") return False def _execute(self, context): - target_representation = tool.Ifc.get().by_id(self.ifc_definition_id) - target = target_representation.ContextOfItems - is_subcontext = target.is_a("IfcGeometricRepresentationSubContext") - for obj in set(context.selected_objects + [context.active_object]): - element = tool.Ifc.get_entity(obj) - if not element: - continue - if not obj.mode == "OBJECT": - continue - if obj == context.active_object: - representation = target_representation - else: - if is_subcontext: - representation = ifcopenshell.util.representation.get_representation( - element, target.ContextType, target.ContextIdentifier, target.TargetView - ) - else: - representation = ifcopenshell.util.representation.get_representation(element, target.ContextType) - if not representation: - continue - core.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - should_reload=self.should_reload, - is_global=self.should_switch_all_meshes, - should_sync_changes_first=True, - ) + context = tool.Ifc.get().by_id(self.ifc_definition_id).ContextOfItems + for obj in tool.Blender.get_selected_objects(): + if ( + (element := tool.Ifc.get_entity(obj)) + and obj.mode == "OBJECT" + and (representation := ifcopenshell.util.representation.get_representation(element, context)) + ): + core.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=self.should_reload, + is_global=self.should_switch_all_meshes, + should_sync_changes_first=True, + ) class RemoveRepresentation(bpy.types.Operator, tool.Ifc.Operator): @@ -384,7 +383,7 @@ 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.run("grid.create_axis_curve", self.file, **{"axis_curve": obj, "grid_axis": product}) + ifcopenshell.api.grid.create_axis_curve(self.file, axis_curve=obj, grid_axis=product) return elif product.is_a("IfcRelSpaceBoundary"): # TODO refactor @@ -418,20 +417,11 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): if tool.Drawing.is_annotation_object_type(element, ("FALL", "SECTION_LEVEL", "PLAN_LEVEL")): context_of_items = tool.Drawing.get_annotation_context("MODEL_VIEW") - gprop = context.scene.BIMGeoreferenceProperties - coordinate_offset = None - if ( - gprop.has_blender_offset - and obj.BIMObjectProperties.blender_offset_type == "CARTESIAN_POINT" - and obj.BIMObjectProperties.cartesian_point_offset - ): - coordinate_offset = Vector(map(float, obj.BIMObjectProperties.cartesian_point_offset.split(","))) - representation_data = { "context": context_of_items, "blender_object": obj, "geometry": obj.data, - "coordinate_offset": coordinate_offset, + "coordinate_offset": tool.Geometry.get_cartesian_point_offset(obj), "total_items": max(1, len(obj.material_slots)), "should_force_faceted_brep": tool.Geometry.should_force_faceted_brep(), "should_force_triangulation": tool.Geometry.should_force_triangulation(), @@ -612,10 +602,11 @@ class OverrideDelete(bpy.types.Operator): return self.execute(context) def draw(self, context): - row = self.layout.row() - row.label(text="Warning: Faster deletion will use more memory.", icon="ERROR") row = self.layout.row() row.prop(self, "is_batch", text="Enable Faster Deletion") + if self.is_batch: + row = self.layout.row() + row.label(text="Warning: Faster deletion will use more memory.", icon="ERROR") def _execute(self, context): start_time = time() @@ -625,12 +616,21 @@ class OverrideDelete(bpy.types.Operator): self.process_arrays(context) for obj in context.selected_objects: + try: + obj.name + except: + continue element = tool.Ifc.get_entity(obj) if element: + if tool.Geometry.is_locked(element): + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be deleted.") + continue if ifcopenshell.util.element.get_pset(element, "BBIM_Array"): self.report({"INFO"}, "Elements that are part of an array cannot be deleted.") return {"FINISHED"} tool.Geometry.delete_ifc_object(obj) + elif tool.Geometry.is_representation_item(obj): + tool.Geometry.delete_ifc_item(obj) else: bpy.data.objects.remove(obj) @@ -734,10 +734,11 @@ class OverrideOutlinerDelete(bpy.types.Operator): return self.execute(context) def draw(self, context): - row = self.layout.row() - row.label(text="Warning: Faster deletion will use more memory.", icon="ERROR") row = self.layout.row() row.prop(self, "is_batch", text="Enable Faster Deletion") + if self.is_batch: + row = self.layout.row() + row.label(text="Warning: Faster deletion will use more memory.", icon="ERROR") def _execute(self, context): if self.is_batch: @@ -754,7 +755,12 @@ class OverrideOutlinerDelete(bpy.types.Operator): elif item.bl_rna.identifier == "Object": objects_to_delete.add(bpy.data.objects.get(item.name)) for obj in objects_to_delete: - if tool.Ifc.get_entity(obj): + if element := tool.Ifc.get_entity(obj): + if tool.Geometry.is_locked(element): + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be deleted.") + if collection := obj.BIMObjectProperties.collection: + collections_to_delete.discard(collection) + continue tool.Geometry.delete_ifc_object(obj) else: bpy.data.objects.remove(obj) @@ -866,10 +872,10 @@ class OverrideDuplicateMove(bpy.types.Operator): ) obj.select_set(False) continue # For now, don't copy drawings until we stabilise a bit more. It's tricky. - elif element.is_a("IfcProject"): - self.report({"INFO"}, "Did not duplicate. IFC can only have one project.") + elif tool.Geometry.is_locked(element): obj.select_set(False) - continue # Only one IfcProject is allowed. + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be duplicated.") + continue linked_non_ifc_object = linked and not element @@ -936,6 +942,7 @@ class OverrideDuplicateMove(bpy.types.Operator): tool.Root.recreate_decompositions(decomposition_relationships, old_to_new) OverrideDuplicateMove.remove_linked_aggregate_data(old_to_new) bonsai.bim.handler.refresh_ui_data() + tool.Root.reload_grid_decorator() return old_to_new @staticmethod @@ -1225,7 +1232,7 @@ class DuplicateLinkedAggregateTo3dCursor(bpy.types.Operator): ) -class RefreshLinkedAggregate(bpy.types.Operator): +class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.refresh_linked_aggregate" bl_label = "IFC Refresh Linked Aggregate" bl_options = {"REGISTER", "UNDO"} @@ -1234,14 +1241,6 @@ class RefreshLinkedAggregate(bpy.types.Operator): def poll(cls, context): return len(context.selected_objects) > 0 - def execute(self, context): - # Deep magick from the dawn of time - if IfcStore.get_file(): - IfcStore.execute_ifc_operator(self, context) - return {"FINISHED"} - - return {"FINISHED"} - def _execute(self, context): self.new_active_obj = None self.group_name = "BBIM_Linked_Aggregate" @@ -1458,6 +1457,9 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): bl_label = "IFC Join" bl_options = {"REGISTER", "UNDO"} + target: bpy.types.Object + target_element: ifcopenshell.entity_instance + def invoke(self, context, event): if not tool.Ifc.get(): return bpy.ops.object.join() @@ -1471,17 +1473,18 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): return self.target = context.active_object - self.target_element = tool.Ifc.get_entity(self.target) - if self.target_element: + if target_element := tool.Ifc.get_entity(self.target): + self.target_element = target_element return self.join_ifc_obj() return self.join_blender_obj() - def join_ifc_obj(self): + def join_ifc_obj(self) -> None: 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) - if representation.RepresentationType in ("Tessellation", "Brep"): + representation_type = representation.RepresentationType + if representation_type in ("Tessellation", "Brep"): for obj in bpy.context.selected_objects: if obj == self.target: continue @@ -1490,10 +1493,22 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.run("root.remove_product", ifc_file, product=element) bpy.ops.object.join() bpy.ops.bim.update_representation(obj=self.target.name, ifc_representation_class="") - elif representation.RepresentationType == "SweptSolid": + else: + # Could support it but need to make sure all objects are on the same plane. + if representation_type == "Curve2D": + self.report({"ERROR"}, "Joining representation of type Curve2D is not supported.") + return + target_placement = np.array(self.target.matrix_world) target_placement[:, 3][:3] /= si_conversion + def apply_placement( + local_pos: npt.NDArray[np.float64], obj_placement: ifcopenshell.util.placement.MatrixType + ) -> npt.NDArray[np.float64]: + position = obj_placement @ local_pos + position = np.linalg.inv(target_placement) @ position + return position + items = list(representation.Items) for obj in bpy.context.selected_objects: if obj == self.target: @@ -1507,25 +1522,41 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): # Only objects of the same representation type can be joined obj_rep = ifc_file.by_id(obj.data.BIMMeshProperties.ifc_definition_id) - if obj_rep.RepresentationType != "SweptSolid": + if obj_rep.RepresentationType != representation_type: obj.select_set(False) continue placement = np.array(obj.matrix_world) placement[:, 3][:3] /= si_conversion - for item in obj_rep.Items: + supported_item_types = ("IfcSweptAreaSolid", "IfcIndexedPolyCurve") + rep_items = obj_rep.Items + for item in rep_items: + if not any(item.is_a(ifc_class) for ifc_class in supported_item_types): + self.report({"ERROR"}, f"Unsupported representation item type for joining: {item.is_a()}.") + return + + for item in rep_items: copied_item = ifcopenshell.util.element.copy_deep(ifc_file, item) for style in item.StyledByItem: copied_style = ifcopenshell.util.element.copy(ifc_file, style) copied_style.Item = copied_item - if copied_item.Position: - position = ifcopenshell.util.placement.get_axis2placement(copied_item.Position) + + if item.is_a("IfcSweptAreaSolid"): + if copied_item.Position: + position = ifcopenshell.util.placement.get_axis2placement(copied_item.Position) + else: + position = np.eye(4, dtype=float) + position = apply_placement(position, placement) + copied_item.Position = builder.create_axis2_placement_3d_from_matrix(position) + elif item.is_a("IfcIndexedPolyCurve"): + points = copied_item.Points + coords = points.CoordList + # We're assuming those are 3D coordinates, since we do not support Curve2D. + points.CoordList = [apply_placement(np.append(c, 1.0), placement).tolist()[:3] for c in coords] else: - position = np.eye(4, dtype=float) - position = placement @ position - position = np.linalg.inv(target_placement) @ position - copied_item.Position = builder.create_axis2_placement_3d_from_matrix(position) + assert False, f"Unexpected item type: {item.is_a()}. This is a bug." + items.append(copied_item) ifcopenshell.api.run("root.remove_product", ifc_file, product=element) representation.Items = items @@ -1541,7 +1572,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): apply_openings=True, ) - def join_blender_obj(self): + def join_blender_obj(self) -> None: for obj in bpy.context.selected_objects: if obj == self.target: continue @@ -1568,122 +1599,12 @@ class OverridePasteBuffer(bpy.types.Operator): return {"FINISHED"} -class OverrideModeSetEdit(bpy.types.Operator): - bl_description = "Switch from Object mode to Edit mode" +class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): + bl_description = "Switch from Object to Item to Edit mode" bl_idname = "bim.override_mode_set_edit" bl_label = "IFC Mode Set Edit" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - - def _execute(self, context): - selected_objs = tool.Blender.get_selected_objects() - active_obj = context.active_object - - if context.active_object: - context.active_object.select_set(True) - - element = tool.Ifc.get_entity(context.active_object) - if element and element.is_a("IfcRelSpaceBoundary"): - return bpy.ops.bim.enable_editing_boundary_geometry() - - for obj in selected_objs: - if not tool.Blender.is_editable(obj): - self.report({"INFO"}, f"Object cannot be edited: {obj.name}") - obj.select_set(False) - continue - element = tool.Ifc.get_entity(obj) - if not element: - continue - representation = tool.Geometry.get_active_representation(obj) - if not representation: - continue - if tool.Blender.Modifier.is_modifier_with_non_editable_path(element): - obj.select_set(False) - continue - - usage_type = tool.Model.get_usage_type(element) - is_profile = True - representation_class = tool.Geometry.get_ifc_representation_class(element, representation) - if usage_type == "PROFILE": - operator = lambda: bpy.ops.bim.hotkey(hotkey="A_E") - elif representation_class and "IfcCircleProfileDef" in representation_class: - self.report({"INFO"}, "Can't edit Circle Profile Extrusion") - obj.select_set(False) - continue - elif ( - tool.Geometry.is_profile_based(obj.data) - or usage_type == "LAYER3" - or tool.Geometry.is_swept_profile(representation) - ): - operator = lambda: bpy.ops.bim.hotkey(hotkey="S_E") - elif tool.Blender.Modifier.is_editing_parameters(obj): - # This should go BEFORE the modifiers - self.report({"INFO"}, "Can't edit path while the modifier parameters are being modified") - obj.select_set(False) - continue - elif tool.Blender.Modifier.is_roof(element): - operator = lambda: bpy.ops.bim.enable_editing_roof_path() - elif tool.Blender.Modifier.is_railing(element): - operator = lambda: bpy.ops.bim.enable_editing_railing_path() - else: - is_profile = False - if is_profile: - if len(context.selected_objects) == 1 and context.active_object == context.selected_objects[0]: - tool.Blender.select_and_activate_single_object(context, obj) - operator() - return {"FINISHED"} - else: - self.report({"INFO"}, "Only a single profile-based representation can be edited at a time.") - obj.select_set(False) - continue - - if tool.Geometry.is_meshlike(representation): - tool.Ifc.edit(obj) - if getattr(element, "HasOpenings", None): - # Mesh elements with openings must disable openings - # so that you can edit the original topology. - core.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - should_reload=True, - is_global=True, - should_sync_changes_first=False, - apply_openings=False, - ) - tool.Geometry.apply_item_ids_as_vertex_groups(obj) - tool.Geometry.dissolve_triangulated_edges(obj) - obj.data.BIMMeshProperties.mesh_checksum = tool.Geometry.get_mesh_checksum(obj.data) - else: - obj.select_set(False) - continue - if len(selected_objs) > 1 and ( - not context.selected_objects or len(context.selected_objects) != len(selected_objs) - ): - # We are trying to edit at least one non-mesh-like object : Display a hint to the user - self.report({"INFO"}, "Only mesh-compatible representations may be edited concurrently in edit mode.") - - if context.active_object not in context.selected_objects: - # The active object is non-mesh-like. Set a valid object (or None) as active - context.view_layer.objects.active = context.selected_objects[0] if context.selected_objects else None - if context.active_object: - 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 - return {"FINISHED"} - - # Restore the selection if nothing worked - for obj in selected_objs: - obj.select_set(True) - context.view_layer.objects.active = active_obj - return {"FINISHED"} - def invoke(self, context, event): return IfcStore.execute_ifc_operator(self, context, is_invoke=True) @@ -1692,68 +1613,139 @@ class OverrideModeSetEdit(bpy.types.Operator): return tool.Blender.toggle_edit_mode(context) return self.execute(context) + def _execute(self, context): + selected_objs = context.selected_objects # Purposely exclude active object -class OverrideModeSetObject(bpy.types.Operator): + if len(selected_objs) == 1 and context.active_object: + self.handle_single_object(context, context.active_object) + elif len(selected_objs) == 0: + tool.Geometry.disable_item_mode() + elif len(selected_objs) > 1: + self.handle_multiple_selected_objects(context) + + def handle_single_object(self, context, obj): + element = tool.Ifc.get_entity(obj) + if obj == context.scene.BIMGeometryProperties.representation_obj: + self.report({"ERROR"}, f"Element '{obj.name}' is in item mode and cannot be edited directly") + elif obj in bpy.context.scene.BIMProjectProperties.clipping_planes_objs: + self.report({"ERROR"}, "Clipping planes cannot be edited") + elif element: + if (usage := tool.Model.get_usage_type(element)) and usage in ("LAYER1", "LAYER2"): + self.report({"INFO"}, f"Parametric {usage} elements cannot be edited directly") + elif tool.Geometry.is_locked(element): + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be edited") + elif usage == "PROFILE": + bpy.ops.bim.hotkey(hotkey="A_E") + elif usage == "LAYER3": + bpy.ops.bim.hotkey(hotkey="S_E") + elif obj.data and tool.Geometry.is_profile_based(obj.data): + bpy.ops.bim.hotkey(hotkey="S_E") + elif element.is_a("IfcRelSpaceBoundary"): + bpy.ops.bim.enable_editing_boundary_geometry() + elif element.is_a("IfcGridAxis"): + self.enable_edit_mode(context) + elif tool.Blender.Modifier.is_editing_parameters(obj): + # This should go BEFORE the modifiers + self.report({"INFO"}, "Can't edit while modifier parameters are being modified") + elif tool.Blender.Modifier.is_roof(element): + bpy.ops.bim.enable_editing_roof_path() + elif tool.Blender.Modifier.is_railing(element): + bpy.ops.bim.enable_editing_railing_path() + else: + bpy.ops.bim.import_representation_items() + elif tool.Geometry.is_representation_item(obj): + self.enable_editing_representation_item(context, obj) + else: # A regular Blender object + self.enable_edit_mode(context) + + def handle_multiple_selected_objects(self, context): + obj = context.active_object + if obj and (tool.Ifc.get_entity(obj) or tool.Geometry.is_representation_item(obj)): + tool.Blender.select_and_activate_single_object(context, obj) + self.handle_single_object(context, obj) + else: + blender_objs = [] + ifc_objs = [] + for selected_obj in tool.Blender.get_selected_objects(): + if tool.Ifc.get_entity(selected_obj) or tool.Geometry.is_representation_item(selected_obj): + ifc_objs.append(selected_obj) + else: + blender_objs.append(selected_obj) + + if blender_objs: + for obj in ifc_objs: + obj.select_set(False) + context.view_layer.objects.active = blender_objs[0] + blender_objs[0].select_set(True) + return tool.Blender.toggle_edit_mode(context) + elif ifc_objs: + obj = ifc_objs[0] + tool.Blender.select_and_activate_single_object(context, obj) + self.handle_single_object(obj) + + def enable_editing_representation_item(self, context, obj): + item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + 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) + self.enable_edit_mode(context) + elif item.is_a("IfcSweptAreaSolid"): + tool.Geometry.sync_item_positions() + tool.Model.import_profile(item.SweptArea, obj=obj) + obj.data.BIMMeshProperties.ifc_definition_id = item.id() + self.enable_edit_mode(context) + ProfileDecorator.install(context) + if not bpy.app.background: + tool.Blender.set_viewport_tool("bim.cad_tool") + else: + self.report({"INFO"}, f"Editing {item.is_a()} geometry is not supported") + + 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 + + +class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): + bl_description = "Switch from Edit to Item or Object mode" bl_idname = "bim.override_mode_set_object" bl_label = "IFC Mode Set Object" bl_options = {"REGISTER", "UNDO"} should_save: bpy.props.BoolProperty(name="Should Save", default=True) - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - - def _execute(self, context): - if not context.active_object: - return {"FINISHED"} - for obj in self.edited_objs: - if self.should_save: - bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="") - if getattr(tool.Ifc.get_entity(obj), "HasOpenings", False): - self.reload_representation(obj) - else: - self.reload_representation(obj) - tool.Ifc.finish_edit(obj) - - for obj in self.unchanged_objs_with_openings: - self.reload_representation(obj) - tool.Ifc.finish_edit(obj) - return {"FINISHED"} - - def reload_representation(self, obj): - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) - core.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - should_reload=True, - is_global=True, - should_sync_changes_first=False, - apply_openings=True, - ) - def invoke(self, context, event): return IfcStore.execute_ifc_operator(self, context, is_invoke=True) def _invoke(self, context, event): + if not tool.Ifc.get(): + return tool.Blender.toggle_edit_mode(context) + if not context.active_object: return {"FINISHED"} self.is_valid = True - bpy.ops.object.mode_set(mode="EDIT", toggle=True) + tool.Blender.toggle_edit_mode(context) context.scene.BIMGeometryProperties.is_changing_mode = True - if context.scene.BIMGeometryProperties.mode != "OBJECT": - context.scene.BIMGeometryProperties.mode = "OBJECT" + if context.scene.BIMGeometryProperties.representation_obj: + if context.scene.BIMGeometryProperties.mode != "ITEM": + context.scene.BIMGeometryProperties.mode = "ITEM" + else: + if context.scene.BIMGeometryProperties.mode != "OBJECT": + context.scene.BIMGeometryProperties.mode = "OBJECT" context.scene.BIMGeometryProperties.is_changing_mode = False - if not tool.Ifc.get(): - return {"FINISHED"} - if context.active_object: element = tool.Ifc.get_entity(context.active_object) if element and element.is_a("IfcRelSpaceBoundary"): return bpy.ops.bim.edit_boundary_geometry() + elif tool.Geometry.is_representation_item(context.active_object): + self.edit_representation_item(context.active_object) + return {"FINISHED"} objs = context.selected_objects or [context.active_object] @@ -1799,9 +1791,133 @@ class OverrideModeSetObject(bpy.types.Operator): 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): + self.is_valid = False + self.should_save = False + if obj.data.BIMMeshProperties.mesh_checksum != tool.Geometry.get_mesh_checksum(obj.data): + self.edited_objs.append(obj) + else: + tool.Ifc.finish_edit(obj) return self.execute(context) + def _execute(self, context): + if not context.active_object: + return {"FINISHED"} + for obj in self.edited_objs: + if self.should_save: + bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="") + if getattr(tool.Ifc.get_entity(obj), "HasOpenings", False): + tool.Geometry.reload_representation(obj) + else: + tool.Geometry.reload_representation(obj) + tool.Ifc.finish_edit(obj) + + for obj in self.unchanged_objs_with_openings: + tool.Geometry.reload_representation(obj) + tool.Ifc.finish_edit(obj) + return {"FINISHED"} + + def edit_representation_item(self, obj): + item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + if tool.Geometry.is_meshlike_item(item): + if tool.Geometry.has_geometric_data(obj) and obj.data.polygons: + if obj.data.BIMMeshProperties.mesh_checksum == tool.Geometry.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()) + + rep_obj = bpy.context.scene.BIMGeometryProperties.representation_obj + if coordinate_offset := tool.Geometry.get_cartesian_point_offset(rep_obj): + verts = [(v.co + coordinate_offset) / unit_scale for v in obj.data.vertices] + else: + verts = [v.co / unit_scale for v in obj.data.vertices] + + faces = [p.vertices[:] for p in obj.data.polygons] + if item.is_a("IfcAdvancedBrep"): + new_item = builder.faceted_brep(verts, faces) + else: + new_item = builder.mesh(verts, faces) + 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() + tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + else: + tool.Geometry.import_item(obj) + elif item.is_a("IfcSweptAreaSolid"): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + ProfileDecorator.uninstall() + + profile = tool.Model.export_profile(obj) + + if not profile: + + def msg(self, context): + self.layout.label(text="INVALID PROFILE") + + bpy.context.window_manager.popup_menu(msg, title="Error", icon="ERROR") + ProfileDecorator.install(bpy.context) + self.enable_edit_mode(bpy.context) + return + + old_profile = item.SweptArea + for inverse in tool.Ifc.get().get_inverse(old_profile): + ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_profile) + + tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.import_item(obj) + tool.Geometry.import_item_attributes(obj) + + element = tool.Ifc.get_entity(bpy.context.scene.BIMGeometryProperties.representation_obj) + # Only certain classes should have a footprint + if element.is_a() in ("IfcSlab", "IfcRamp"): + footprint_context = ifcopenshell.util.representation.get_context( + tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" + ) + if footprint_context: + if profile.is_a("IfcCompositeProfileDef"): + profiles = profile.Profiles + else: + profiles = [profile] + curves = [] + for profile in profiles: + curves.append(profile.OuterCurve) + if profile.is_a("IfcArbitraryProfileDefWithVoids"): + curves.extend(profile.InnerCurves) + new_footprint = ifcopenshell.api.run( + "geometry.add_footprint_representation", + tool.Ifc.get(), + context=footprint_context, + curves=curves, + ) + old_footprint = ifcopenshell.util.representation.get_representation( + element, "Plan", "FootPrint", "SKETCH_VIEW" + ) + if old_footprint: + for inverse in tool.Ifc.get().get_inverse(old_footprint): + ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint) + bonsai.core.geometry.remove_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint + ) + else: + ifcopenshell.api.run( + "geometry.assign_representation", + tool.Ifc.get(), + product=element, + representation=new_footprint, + ) + + 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 + class FlipObject(bpy.types.Operator): bl_idname = "bim.flip_object" @@ -1840,7 +1956,8 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): if bpy.context.active_object.data and hasattr(bpy.context.active_object.data, "BIMMeshProperties"): active_representation_id = bpy.context.active_object.data.BIMMeshProperties.ifc_definition_id element = tool.Ifc.get().by_id(active_representation_id) - if not element.is_a("IfcShapeRepresentation"): + # IfcShapeRepresentation or IfcTopologyRepresentation. + if not element.is_a("IfcShapeModel"): return queue = list(element.Items) while queue: @@ -1893,7 +2010,6 @@ class RemoveRepresentationItem(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_representation_item" bl_label = "Remove Representation Item" bl_options = {"REGISTER", "UNDO"} - representation_item_id: bpy.props.IntProperty() @classmethod @@ -1907,7 +2023,6 @@ class RemoveRepresentationItem(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMGeometryProperties ifc_file = tool.Ifc.get() representation_item = ifc_file.by_id(self.representation_item_id) @@ -2025,9 +2140,9 @@ class UnassignRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator): # Retrieve styles applied to the active representation item active_styles = set() - if hasattr(active_representation_item, 'StyledByItem'): + if hasattr(active_representation_item, "StyledByItem"): for styled_by_item in active_representation_item.StyledByItem: - if hasattr(styled_by_item, 'Styles'): + if hasattr(styled_by_item, "Styles"): active_styles.update(styled_by_item.Styles) if not active_styles: @@ -2056,9 +2171,9 @@ class UnassignRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator): if representation_item: # Retrieve styles for the current representation item styles = set() - if hasattr(representation_item, 'StyledByItem'): + if hasattr(representation_item, "StyledByItem"): for styled_by_item in representation_item.StyledByItem: - if hasattr(styled_by_item, 'Styles'): + if hasattr(styled_by_item, "Styles"): styles.update(styled_by_item.Styles) # Unassign matching styles @@ -2072,8 +2187,6 @@ class UnassignRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.disable_editing_representation_items() bpy.ops.bim.enable_editing_representation_items() - - class EnableEditingRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_representation_item_shape_aspect" @@ -2200,3 +2313,176 @@ class RemoveRepresentationItemFromShapeAspect(bpy.types.Operator, tool.Ifc.Opera if not styled_item.Styles: ifc_file.remove(styled_item) + + +class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.import_representation_items" + bl_label = "Import Representation Items" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + obj = context.active_object + assert obj + props = context.scene.BIMGeometryProperties + if previous_obj := props.representation_obj: + previous_obj.hide_set(False) + props.representation_obj = obj + obj.hide_set(True) + tool.Geometry.lock_object(obj) + + context.scene.BIMGeometryProperties.is_changing_mode = True + if context.scene.BIMGeometryProperties.mode != "ITEM": + context.scene.BIMGeometryProperties.mode = "ITEM" + context.scene.BIMGeometryProperties.is_changing_mode = False + + tool.Loader.load_settings() + + data = obj.data + assert data + item_ids = data["ios_item_ids"] if "ios_item_ids" in data else data["ios_edges_item_ids"] + for item_id in set(item_ids): + item = tool.Ifc.get().by_id(item_id) + item_mesh = bpy.data.meshes.new(f"Item/{item.is_a()}/{item_id}") + item_mesh.BIMMeshProperties.ifc_definition_id = item_id + + item_obj = bpy.data.objects.new(f"Item/{item.is_a()}/{item_id}", item_mesh) + item_obj.matrix_world = obj.matrix_world + item_obj.show_in_front = True + bpy.context.collection.objects.link(item_obj) + + new = props.item_objs.add() + new.obj = item_obj + + tool.Geometry.import_item(item_obj) + tool.Geometry.import_item_attributes(item_obj) + + if tool.Geometry.is_meshlike_item(item): + tool.Geometry.lock_object(item_obj) + + +class UpdateItemAttributes(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.update_item_attributes" + bl_label = "Update Item Attributes" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + obj = context.active_object + props = obj.data.BIMMeshProperties + item = tool.Ifc.get().by_id(props.ifc_definition_id) + for attribute in props.item_attributes: + if attribute.name == "Depth": + item.Depth = attribute.float_value + tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.import_item(obj) + + +class AddMeshlikeItem(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_meshlike_item" + bl_label = "Add Meshlike Item" + bl_options = {"REGISTER", "UNDO"} + shape: bpy.props.StringProperty(name="Shape") + + def _execute(self, context): + props = context.scene.BIMGeometryProperties + mesh = bpy.data.meshes.new("Tmp") + obj = bpy.data.objects.new("Tmp", mesh) + scene = bpy.context.scene + scene.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bm = bmesh.new() + matrix = props.representation_obj.matrix_world.copy() + matrix.translation = context.scene.cursor.location + matrix = props.representation_obj.matrix_world.inverted() @ matrix + if self.shape == "CUBE": + bmesh.ops.create_cube(bm, matrix=matrix, size=0.5) + elif self.shape == "PLANE": + bmesh.ops.create_grid(bm, matrix=matrix, size=0.5) + elif self.shape == "CIRCLE": + bmesh.ops.create_circle(bm, matrix=matrix, radius=0.25, segments=16, cap_ends=True) + elif self.shape == "UVSPHERE": + bmesh.ops.create_uvsphere(bm, matrix=matrix, radius=0.25, u_segments=16, v_segments=16) + elif self.shape == "ICOSPHERE": + bmesh.ops.create_icosphere(bm, matrix=matrix, radius=0.25, subdivisions=2) + elif self.shape == "CYLINDER": + # Cone is legitimate. + bmesh.ops.create_cone(bm, matrix=matrix, radius1=0.25, radius2=0.25, depth=0.5, segments=16, cap_ends=True) + + bm.to_mesh(mesh) + bm.free() + mesh.update() + obj.matrix_world = props.representation_obj.matrix_world + obj.show_in_front = True + new = props.item_objs.add() + new.obj = obj + + tool.Geometry.lock_object(obj) + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + verts = [v.co / unit_scale for v in obj.data.vertices] + faces = [p.vertices[:] for p in obj.data.polygons] + + representation = tool.Geometry.get_active_representation(props.representation_obj) + representation = ifcopenshell.util.representation.resolve_representation(representation) + + if representation.RepresentationType in ("Brep", "AdvancedBrep"): + item = builder.faceted_brep(verts, faces) + elif representation.RepresentationType in ("Tessellation"): + item = builder.mesh(verts, faces) + + representation.Items = list(representation.Items) + [item] + tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" + obj.data.BIMMeshProperties.ifc_definition_id = item.id() + + +class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_swept_area_solid_item" + bl_label = "Add Swept Area Solid Item" + bl_options = {"REGISTER", "UNDO"} + shape: bpy.props.StringProperty(name="Shape") + + def _execute(self, context): + props = context.scene.BIMGeometryProperties + mesh = bpy.data.meshes.new("Tmp") + obj = bpy.data.objects.new("Tmp", mesh) + scene = bpy.context.scene + scene.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + new = props.item_objs.add() + new.obj = obj + + matrix = props.representation_obj.matrix_world.copy() + matrix.translation = context.scene.cursor.location + local_matrix = props.representation_obj.matrix_world.inverted() @ matrix + + obj.show_in_front = True + obj.matrix_world = matrix + tool.Geometry.record_object_position(obj) + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + if self.shape == "CUBE": + curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale) + elif self.shape == "CYLINDER": + curve = builder.circle(radius=0.25) + item = builder.extrude( + curve, + magnitude=0.5 / unit_scale, + position=local_matrix.translation, + position_x_axis=local_matrix.col[0].to_3d(), + position_z_axis=local_matrix.col[2].to_3d(), + ) + + representation = tool.Geometry.get_active_representation(props.representation_obj) + representation = ifcopenshell.util.representation.resolve_representation(representation) + + representation.Items = list(representation.Items) + [item] + tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + + obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" + obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Geometry.import_item(obj) + tool.Geometry.import_item_attributes(obj) diff --git a/src/bonsai/bonsai/bim/module/geometry/prop.py b/src/bonsai/bonsai/bim/module/geometry/prop.py index 641695a16e..53a7f84d87 100644 --- a/src/bonsai/bonsai/bim/module/geometry/prop.py +++ b/src/bonsai/bonsai/bim/module/geometry/prop.py @@ -18,7 +18,7 @@ import bpy import bonsai.tool as tool -from bonsai.bim.prop import StrProperty, Attribute +from bonsai.bim.prop import StrProperty, Attribute, ObjProperty from bonsai.bim.module.geometry.data import RepresentationsData, ViewportData from bpy.types import PropertyGroup from bpy.props import ( @@ -42,10 +42,29 @@ def get_contexts(self, context): def update_mode(self, context): if self.is_changing_mode: return - if self.mode == "OBJECT": - bpy.ops.bim.override_mode_set_object("INVOKE_DEFAULT") - elif self.mode == "EDIT": - bpy.ops.bim.override_mode_set_edit("INVOKE_DEFAULT") + if context.mode.startswith("EDIT"): + if self.mode == "OBJECT": + bpy.ops.bim.override_mode_set_object("INVOKE_DEFAULT") + tool.Geometry.disable_item_mode() + elif self.mode == "ITEM": + bpy.ops.bim.override_mode_set_object("INVOKE_DEFAULT") + elif self.mode == "EDIT": + pass + else: + if self.mode == "OBJECT": + tool.Geometry.disable_item_mode() + elif self.mode == "ITEM": + if not self.representation_obj: + bpy.ops.bim.import_representation_items() + elif self.mode == "EDIT": + bpy.ops.bim.override_mode_set_edit("INVOKE_DEFAULT") + + +def update_representation_obj(self, context): + for item_obj in self.item_objs: + if item_obj.obj: + bpy.data.objects.remove(item_obj.obj) + self.item_objs.clear() def get_mode(self, context): @@ -100,6 +119,16 @@ class RepresentationItem(PropertyGroup): tags: StringProperty(name="Tags") +class RepresentationItemObject(PropertyGroup): + name: StringProperty(name="Name") + ifc_definition_id: IntProperty(name="IFC Definition ID") + obj: PointerProperty(type=bpy.types.Object) + verts: StringProperty(name="Verts") + edges: StringProperty(name="Edges") + special_verts: StringProperty(name="Special Verts") + special_edges: StringProperty(name="Special Edges") + + class ShapeAspect(PropertyGroup): name: StringProperty( name="Name", @@ -137,6 +166,10 @@ class BIMGeometryProperties(PropertyGroup): should_force_triangulation: BoolProperty(name="Force Triangulation", default=False) is_changing_mode: BoolProperty(name="Is Changing Mode", default=False) mode: EnumProperty(items=get_mode, name="IFC Interaction Mode", update=update_mode) + representation_obj: PointerProperty( + name="Representation Object", type=bpy.types.Object, update=update_representation_obj + ) + item_objs: CollectionProperty(name="Item Objects", type=RepresentationItemObject) def is_object_valid_for_representation_copy(self, obj: bpy.types.Object) -> bool: return bool(obj != bpy.context.active_object and obj.data) diff --git a/src/bonsai/bonsai/bim/module/georeference/decorator.py b/src/bonsai/bonsai/bim/module/georeference/decorator.py index 0f284673a2..3381da6501 100644 --- a/src/bonsai/bonsai/bim/module/georeference/decorator.py +++ b/src/bonsai/bonsai/bim/module/georeference/decorator.py @@ -154,6 +154,7 @@ class GeoreferenceDecorator: location = location.normalized() * 3 text += "\n(Warning: Actual XYZ Not Shown)" self.draw_text_at_position(context, text, location) + blf.disable(self.font_id, blf.SHADOW) def draw_text_at_position(self, context, text, position): coords_2d = location_3d_to_region_2d(context.region, context.region_data, position) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/data.py b/src/bonsai/bonsai/bim/module/ifcgit/data.py index 836f30ae13..2df65540ab 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/data.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/data.py @@ -154,15 +154,17 @@ class IfcGitData: @classmethod def commit(cls): props = bpy.context.scene.IfcGitProperties - if len(props.ifcgit_commits) > 0: + if cls.repo() and len(props.ifcgit_commits) > 0: item = props.ifcgit_commits[props.commit_index] - if cls.repo(): + try: return cls.repo().commit(rev=item.hexsha) + except ValueError: + return @classmethod def current_revision(cls): props = bpy.context.scene.IfcGitProperties - if len(props.ifcgit_commits) > 0: + if cls.repo() and cls.repo().head.is_valid() and len(props.ifcgit_commits) > 0: return tool.IfcGitRepo.repo.commit() @classmethod diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 55d98e6434..b059c71b2d 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -98,6 +98,7 @@ class DiscardUncommitted(bpy.types.Operator): core.discard_uncommitted(tool.IfcGit, tool.Ifc) refresh() + tool.IfcGit.decolourise() return {"FINISHED"} @@ -132,7 +133,7 @@ class CommitChanges(bpy.types.Operator): repo = IfcGitData.data["repo"] core.commit_changes(tool.IfcGit, tool.Ifc, repo) - bpy.ops.ifcgit.refresh() + core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) refresh() return {"FINISHED"} @@ -162,7 +163,7 @@ class AddTag(bpy.types.Operator): repo = IfcGitData.data["repo"] core.add_tag(tool.IfcGit, repo) - bpy.ops.ifcgit.refresh() + core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) refresh() return {"FINISHED"} @@ -179,7 +180,7 @@ class DeleteTag(bpy.types.Operator): repo = IfcGitData.data["repo"] core.delete_tag(tool.IfcGit, repo, self.tag_name) - bpy.ops.ifcgit.refresh() + core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) refresh() return {"FINISHED"} @@ -204,6 +205,7 @@ class RefreshGit(bpy.types.Operator): repo = IfcGitData.data["repo"] core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) refresh() + tool.IfcGit.decolourise() return {"FINISHED"} @@ -342,7 +344,7 @@ class AddRemote(bpy.types.Operator): repo = IfcGitData.data["repo"] core.add_remote(tool.IfcGit, repo) - bpy.ops.ifcgit.refresh() + core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) refresh() return {"FINISHED"} @@ -358,7 +360,7 @@ class DeleteRemote(bpy.types.Operator): repo = IfcGitData.data["repo"] core.delete_remote(tool.IfcGit, repo) - bpy.ops.ifcgit.refresh() + core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) refresh() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 38bfd83cb3..860e4325d3 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -1,5 +1,7 @@ import bpy import time +import os + from bonsai.bim.module.ifcgit.data import IfcGitData @@ -32,7 +34,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): row = layout.row() if path_ifc: - if IfcGitData.data["repo"]: + if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir): name_ifc = IfcGitData.data["name_ifc"] row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM") if name_ifc in IfcGitData.data["untracked_files"]: @@ -44,6 +46,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): else: row.label(text=name_ifc, icon="FILE") else: + IfcGitData.load() row.operator( "ifcgit.createrepo", text="Create '" + IfcGitData.data["dir_name"] + "' repository", @@ -193,7 +196,10 @@ class COMMIT_UL_List(bpy.types.UIList): # TODO Figure how this "item" can be acesse in "data.py" # so it's possible to move the ".commit" - commit = IfcGitData.data["repo"].commit(rev=item.hexsha) + try: + commit = IfcGitData.data["repo"].commit(rev=item.hexsha) + except ValueError: + return lookup = IfcGitData.data["branches_by_hexsha"] refs = "" diff --git a/src/bonsai/bonsai/bim/module/layer/operator.py b/src/bonsai/bonsai/bim/module/layer/operator.py index f5d62bf70f..c633b50315 100644 --- a/src/bonsai/bonsai/bim/module/layer/operator.py +++ b/src/bonsai/bonsai/bim/module/layer/operator.py @@ -78,14 +78,11 @@ class DisableEditingLayer(bpy.types.Operator): return {"FINISHED"} -class AddPresentationLayer(bpy.types.Operator): +class AddPresentationLayer(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_presentation_layer" bl_label = "Add Layer" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): result = ifcopenshell.api.run("layer.add_layer", IfcStore.get_file()) bpy.ops.bim.load_layers() @@ -93,14 +90,11 @@ class AddPresentationLayer(bpy.types.Operator): return {"FINISHED"} -class EditPresentationLayer(bpy.types.Operator): +class EditPresentationLayer(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_presentation_layer" bl_label = "Edit Layer" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): props = context.scene.BIMLayerProperties attributes = {} @@ -117,15 +111,12 @@ class EditPresentationLayer(bpy.types.Operator): return {"FINISHED"} -class RemovePresentationLayer(bpy.types.Operator): +class RemovePresentationLayer(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_presentation_layer" bl_label = "Remove Presentation Layer" bl_options = {"REGISTER", "UNDO"} layer: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): props = context.scene.BIMLayerProperties self.file = IfcStore.get_file() @@ -134,7 +125,7 @@ class RemovePresentationLayer(bpy.types.Operator): return {"FINISHED"} -class AssignPresentationLayer(bpy.types.Operator): +class AssignPresentationLayer(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_presentation_layer" bl_label = "Assign Presentation Layer" bl_description = "Assign presentation layer to the active representation of the active object" @@ -142,9 +133,6 @@ class AssignPresentationLayer(bpy.types.Operator): item: bpy.props.StringProperty() layer: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): item = bpy.data.meshes.get(self.item) if self.item else context.active_object.data self.file = IfcStore.get_file() @@ -159,7 +147,7 @@ class AssignPresentationLayer(bpy.types.Operator): return {"FINISHED"} -class UnassignPresentationLayer(bpy.types.Operator): +class UnassignPresentationLayer(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_presentation_layer" bl_label = "Unassign Presentation Layer" bl_description = "Unassign presentation layer from the active representation of the active object" @@ -167,9 +155,6 @@ class UnassignPresentationLayer(bpy.types.Operator): item: bpy.props.StringProperty() layer: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): item = bpy.data.meshes.get(self.item) if self.item else context.active_object.data self.file = IfcStore.get_file() diff --git a/src/bonsai/bonsai/bim/module/light/__init__.py b/src/bonsai/bonsai/bim/module/light/__init__.py index ea4fb7e05c..04bd22a80c 100644 --- a/src/bonsai/bonsai/bim/module/light/__init__.py +++ b/src/bonsai/bonsai/bim/module/light/__init__.py @@ -39,6 +39,10 @@ classes = ( operator.ViewFromSun, operator.RefreshIFCMaterials, operator.UnmapMaterial, + operator.RADIANCE_OT_select_camera, + operator.RADIANCE_OT_export_material_mappings, + operator.RADIANCE_OT_import_material_mappings, + operator.RADIANCE_OT_open_spectraldb, prop.RadianceMaterial, prop.BIMSolarProperties, prop.RadianceExporterProperties, diff --git a/src/bonsai/bonsai/bim/module/light/list.py b/src/bonsai/bonsai/bim/module/light/list.py index c485cc4259..0536284e7a 100644 --- a/src/bonsai/bonsai/bim/module/light/list.py +++ b/src/bonsai/bonsai/bim/module/light/list.py @@ -1,3 +1,22 @@ +# 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 import json import os @@ -10,6 +29,15 @@ class MATERIAL_UL_radiance_materials(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): if self.layout_type in {"DEFAULT", "COMPACT"}: row = layout.row(align=True) + + # Adding color preview or glass icon + if item.category == "Glass": + row.label(icon="SHADING_TEXTURE") + else: + color_rect = row.row() + color_rect.prop(item, "color", text="") + color_rect.scale_x = 0.3 + row.prop(item, "name", text="", emboss=False, icon_value=icon) if item.is_mapped: row.label(text=f"{item.category} - {item.subcategory}") diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index 093cafedf8..7ad7a5da55 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -30,11 +30,16 @@ import bonsai.tool as tool from pathlib import Path from typing import Union, Optional, Sequence import json +import math +import time import ifcopenshell +import webbrowser import ifcopenshell.geom import multiprocessing from mathutils import Vector from bonsai.bim.module.light.data import SolarData +from bpy_extras.io_utils import ExportHelper +from bpy_extras.io_utils import ImportHelper ifc_materials = [] @@ -49,8 +54,15 @@ class ExportOBJ(bpy.types.Operator): bl_label = "Export" bl_description = "Export the IFC to OBJ" - def execute(self, context): + @classmethod + def poll(cls, context): + props = context.scene.radiance_exporter_properties + if not props.should_load_from_memory and not props.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 + def execute(self, context): # Get the output directory should_load_from_memory = context.scene.radiance_exporter_properties.should_load_from_memory output_dir = context.scene.radiance_exporter_properties.output_dir @@ -95,14 +107,10 @@ class ExportOBJ(bpy.types.Operator): while True: shape = iterator.get() materials = shape.geometry.materials - # print(shape.geometry) material_ids = shape.geometry.material_ids # material_names = shape.geometry.material_names - # print(material_ids) for material in materials: - # print(material, dir(material)) - # print(material.name) ifc_materials.append(material.name) serialiser.write(shape) @@ -129,10 +137,15 @@ class RadianceRender(bpy.types.Operator): self.report({"ERROR"}, "PyRadiance is not available. Cannot perform rendering.") return {"CANCELLED"} - # Get the resolution from the user input - + print("Starting Radiance rendering process...") props = context.scene.radiance_exporter_properties resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y + + context.scene.render.resolution_x = resolution_x + context.scene.render.resolution_y = resolution_y + + aspect_ratio = resolution_x / resolution_y + quality = props.radiance_quality.upper() detail = props.radiance_detail.upper() variability = props.radiance_variability.upper() @@ -141,6 +154,11 @@ class RadianceRender(bpy.types.Operator): output_file_format = props.output_file_format use_hdr = props.use_hdr choose_hdr_image = props.choose_hdr_image + + print(f"Resolution: {resolution_x}x{resolution_y}") + print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}") + print(f"Output directory: {output_dir}") + if use_hdr: hdr_image = "noon_grass_2k.hdr" hdr_mask = "noon_grass_2k_mask.hdr" @@ -148,30 +166,34 @@ class RadianceRender(bpy.types.Operator): hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_image) hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_mask) sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", sky_map_cal) - # os.chdir(output_dir) obj_file_path = os.path.join(output_dir, "model.obj") sun_props = context.scene.BIMSolarProperties + sun_pos_props = context.scene.sun_pos_properties sky_file_path = os.path.join(output_dir, "sky.rad") - latitude = sun_props.latitude - longitude = sun_props.longitude - timezone = sun_props.timezone - month = sun_props.month - day = sun_props.day - hour = sun_props.hour - minute = sun_props.minute + # latitude = sun_props.latitude + # longitude = sun_props.longitude + # month = sun_props.month + # day = sun_props.day + # hour = sun_props.hour + # minute = sun_props.minute - print("Sun Properties:") - print("Latitude: ", latitude) - print("Longitude: ", longitude) - print("Timezone: ", timezone) - print("Month: ", month) - print("Day: ", day) - print("Hour: ", hour) - print("Minute: ", minute) + # print("Sun Properties:") + # print("Latitude: ", latitude) + # print("Longitude: ", longitude) + # print("Timezone: ", timezone) + # print("Month: ", month) + # print("Day: ", day) + # print("Hour: ", hour) + # print("Minute: ", minute) + + print("Setting up camera...") + if props.use_active_camera: + camera = context.scene.camera + else: + camera = props.selected_camera - camera = self.get_active_camera(context) if camera is None: self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.") return {"CANCELLED"} @@ -179,19 +201,35 @@ class RadianceRender(bpy.types.Operator): # Get camera position and direction camera_position, camera_direction = self.get_camera_data(camera) - dt = datetime(2024, month, day, hour, minute) + print(f"Camera position: {camera_position}") + print(f"Camera direction: {camera_direction}") + + # sun_position = tool.Blender.get_sun_position_addon() + # azimuth, elevation = sun_position.sun_calc.get_sun_coordinates( + # sun_pos_props.time, + # sun_pos_props.latitude, + # sun_pos_props.longitude, + # -sun_pos_props.UTC_zone, + # sun_pos_props.month, + # sun_pos_props.day, + # sun_pos_props.year, + # ) + + dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute) sky_description = pr.gensky( dt=dt, - latitude=latitude, - longitude=longitude, - timezone=timezone, - year=2024, - sunny_with_sun=True, - sunny_without_sun=False, - cloudy=False, - ground_reflectance=0.2, - turbidity=3.0, + # azimuth=64.1, + # altitude=-26.6, + latitude=sun_props.latitude, + longitude=sun_props.longitude, + year=sun_pos_props.year, + timezone=-int(sun_props.UTC_zone), + # sunny_with_sun=False, + # sunny_without_sun=False, + # cloudy=False, + # ground_reflectance=0.2, + # turbidity=3.0, ) sky_description_str = sky_description.decode("utf-8") @@ -292,11 +330,7 @@ ground_glow source ground props = context.scene.radiance_exporter_properties - if props.use_json_file: - with open(props.json_file, "r") as file: - data = json.load(file) - else: - data = props.get_mappings_dict() + data = props.get_mappings_dict() materials_file = os.path.join(output_dir, "materials.rad") written_materials = set() @@ -318,9 +352,6 @@ ground_glow source ground file.write(material) written_materials.add(material.split()[2]) # Add material name to written set - print(data) - print(default_materials) - for style_id in all_materials: material = next((m for m in props.materials if m.style_id == style_id), None) if material and material.is_mapped: @@ -351,7 +382,7 @@ ground_glow source ground self.report({"INFO"}, "Exported Scene file to: {}".format(scene_file)) - # Py Radiance Rendering code + print("Setting up Radiance scene...") scene = pr.Scene("ascene") material_path = os.path.join(output_dir, "materials.rad") @@ -360,10 +391,39 @@ ground_glow source ground scene.add_material(material_path) scene.add_surface(scene_path) scene.add_source(sky_file_path) + print("Setting up view...") + if camera.data.type == "PERSP": + # Perspective camera + camera_fov = camera.data.angle + # Calculate vertical FOV based on the desired aspect ratio + vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio) - aview = pr.View(position=camera_position, direction=camera_direction) + aview = pr.View( + vtype="v", # Perspective view + position=camera_position, + direction=camera_direction, + vup=(0, 0, 1), # Assuming Z is up + horiz=math.degrees(camera_fov), + vert=math.degrees(vertical_fov), + ) + else: # 'ORTHO' + # Orthographic camera + # Calculate the view size based on the camera's orthographic scale + ortho_scale = camera.data.ortho_scale + view_width = ortho_scale + view_height = ortho_scale / aspect_ratio + + aview = pr.View( + vtype="l", # Parallel projection (orthographic) + position=camera_position, + direction=camera_direction, + vup=(0, 0, 1), # Assuming Z is up + horiz=view_width, + vert=view_height, + ) scene.add_view(aview) - + print("Starting render...") + start_time = time.time() image = pr.render( scene, ambbounce=1, @@ -371,32 +431,34 @@ ground_glow source ground quality=quality, detail=detail, variability=variability, + nproc=multiprocessing.cpu_count(), ) + end_time = time.time() + print(f"Render completed in {end_time - start_time:.2f} seconds") output_hdr_path = os.path.join(output_dir, f"{output_file_name}.{output_file_format.lower()}") - + print(f"Saving HDR output to: {output_hdr_path}") if output_file_format == "HDR": with open(output_hdr_path, "wb") as wtr: wtr.write(image) else: pass - + print("Applying tone mapping...") pcond_image = pr.pcond(hdr=output_hdr_path, human=True) tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff") - + print(f"Saving TIFF output to: {tiff_path}") pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True) - + print("Radiance rendering process completed successfully.") self.report({"INFO"}, "Radiance rendering completed. Output: {}".format(tiff_path)) return {"FINISHED"} def get_active_camera(self, context): - if context.scene.camera: + props = context.scene.radiance_exporter_properties + if props.use_active_camera: return context.scene.camera - for obj in context.scene.objects: - if obj.type == "CAMERA": - return obj - return None + else: + return props.selected_camera def get_camera_data(self, camera): # Get camera position @@ -509,9 +571,29 @@ class RefreshIFCMaterials(bpy.types.Operator): for render_item in style.Styles: if render_item.is_a("IfcSurfaceStyleRendering"): style_id = f"IfcSurfaceStyleRendering-{render_item.id()}" - ifc_materials.append(style_id) style_name = style.Name or f"Unnamed Style {render_item.id()}" - props.add_material_mapping(style_id, style_name) + + # Extract color and transparency + color = (1.0, 1.0, 1.0) # Default white + transparency = 0.0 # Default opaque + if render_item.SurfaceColour: + color = ( + render_item.SurfaceColour.Red, + render_item.SurfaceColour.Green, + render_item.SurfaceColour.Blue, + ) + if hasattr(render_item, "Transparency") and render_item.Transparency is not None: + transparency = render_item.Transparency + + # Add material with color + material = props.add_material_mapping(style_id, style_name) + material.color = color + + # If transparency is high, consider it as glass + if transparency > 0.5: + material.category = "Glass" + material.subcategory = "Clear Glass" + material.is_mapped = True props.active_material_index = 0 if props.materials else -1 @@ -531,3 +613,70 @@ class UnmapMaterial(bpy.types.Operator): material = props.materials[self.material_index] props.unmap_material(material.name) return {"FINISHED"} + + +class RADIANCE_OT_select_camera(bpy.types.Operator): + bl_idname = "radiance.select_camera" + bl_label = "Select Camera" + bl_description = "Select a camera from the viewport" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object is not None and context.object.type == "CAMERA" + + def execute(self, context): + props = context.scene.radiance_exporter_properties + props.selected_camera = context.object + props.use_active_camera = False + return {"FINISHED"} + + +class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper): + bl_idname = "radiance.export_material_mappings" + bl_label = "Export Material Mappings" + bl_description = "Export material mappings to a JSON file" + + filename_ext = ".json" + + def execute(self, context): + props = context.scene.radiance_exporter_properties + mappings = {} + + for material in props.materials: + if material.is_mapped: + mappings[material.style_id] = { + "name": material.name, + "category": material.category, + "subcategory": material.subcategory, + } + + with open(self.filepath, "w") as f: + json.dump(mappings, f, indent=4) + + self.report({"INFO"}, f"Material mappings exported to {self.filepath}") + return {"FINISHED"} + + +class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper): + bl_idname = "radiance.import_material_mappings" + bl_label = "Import Material Mappings" + bl_description = "Import material mappings from a JSON file" + + filename_ext = ".json" + + def execute(self, context): + props = context.scene.radiance_exporter_properties + props.import_mappings(self.filepath) + self.report({"INFO"}, f"Material mappings imported from {self.filepath}") + return {"FINISHED"} + + +class RADIANCE_OT_open_spectraldb(bpy.types.Operator): + bl_idname = "radiance.open_spectraldb" + bl_label = "Open SpectralDB" + bl_description = "Open the SpectralDB website for reference" + + def execute(self, context): + webbrowser.open("https://spectraldb.com") + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index 519cf3583a..acc25f3b42 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -32,6 +32,7 @@ from bpy.props import ( FloatVectorProperty, BoolProperty, CollectionProperty, + PointerProperty, ) import os from bpy.types import PropertyGroup @@ -51,41 +52,28 @@ def get_sites(self, context): def update_latlong(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.latitude = self.latitude - sun_props.longitude = self.longitude - update_sun_path() + update_sun_path(self) def update_hourminute(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.time = self.hour + (self.minute / 60) - update_sun_path() + update_sun_path(self) def update_date(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.month = self.month - sun_props.day = self.day - update_sun_path() + update_sun_path(self) def update_true_north(self, context): - sun_props = context.scene.sun_pos_properties - # Preserve IFC sign convention - sun_props.north_offset = radians(self.true_north * -1) - update_sun_path() + update_sun_path(self) def update_sun_path_size(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.sun_distance = self.sun_path_size - update_sun_path() + update_sun_path(self) def update_display_shadows(self, context): if self.display_shadows: - update_sun_path() + update_sun_path(self) context.scene.render.engine = "BLENDER_WORKBENCH" context.scene.display.shading.light = "FLAT" context.scene.display.shading.show_shadows = True @@ -101,13 +89,18 @@ def update_display_shadows(self, context): def update_display_sun_path(self, context): if self.display_sun_path: - update_sun_path() + update_sun_path(self) SolarDecorator.install(bpy.context) else: SolarDecorator.uninstall() -def update_sun_path(): +def update_resolution(self, context): + context.scene.render.resolution_x = self.radiance_resolution_x + context.scene.render.resolution_y = self.radiance_resolution_y + + +def update_sun_path(self): if not SolarData.is_loaded: SolarData.load() @@ -116,6 +109,16 @@ def update_sun_path(): props = bpy.context.scene.BIMSolarProperties sun_props = bpy.context.scene.sun_pos_properties + + sun_props.sun_distance = self.sun_path_size + sun_props.latitude = self.latitude + sun_props.longitude = self.longitude + sun_props.month = self.month + sun_props.day = self.day + sun_props.time = self.hour + (self.minute / 60) + # Preserve IFC sign convention + sun_props.north_offset = radians(self.true_north * -1) + props.timezone = tzfpy.get_tz(props.longitude, props.latitude) timezone = pytz.timezone(props.timezone) dt = datetime.datetime(sun_props.year, sun_props.month, sun_props.day, props.hour, props.minute) @@ -142,6 +145,10 @@ def update_sun_path(): rotation_euler = Euler((elevation - pi / 2, 0, -azimuth)) rotation_quaternion = rotation_euler.to_quaternion() + props.azimuth = azimuth + props.elevation = elevation + props.UTC_zone = zone + if sun_vector.z < 0: bpy.context.scene.display.light_direction = mat @ Vector((0, 0, 1)) else: @@ -162,14 +169,11 @@ class RadianceMaterial(PropertyGroup): category: StringProperty(name="Category") subcategory: StringProperty(name="Subcategory") is_mapped: BoolProperty(name="Is Mapped", default=False) + color: FloatVectorProperty(name="Color", subtype="COLOR", default=(1.0, 1.0, 1.0), min=0.0, max=1.0, size=3) class RadianceExporterProperties(PropertyGroup): - def update_json_file(self, context): - if self.json_file: - self.json_file = bpy.path.abspath(self.json_file) - def update_output_dir(self, context): if self.output_dir: self.output_dir = bpy.path.abspath(self.output_dir) @@ -184,8 +188,26 @@ class RadianceExporterProperties(PropertyGroup): item.style_id = style_id item.category = "" item.subcategory = "" + item.color = (1.0, 1.0, 1.0) # Default white return item + def import_mappings(self, filepath): + with open(filepath, "r") as f: + mappings = json.load(f) + + for style_id, mapping in mappings.items(): + material = self.get_material_mapping(mapping["name"]) + if material: + material.style_id = style_id + material.category = mapping["category"] + material.subcategory = mapping["subcategory"] + material.is_mapped = True + else: + new_material = self.add_material_mapping(style_id, mapping["name"]) + new_material.category = mapping["category"] + new_material.subcategory = mapping["subcategory"] + new_material.is_mapped = True + def get_material_mapping(self, style_name): return next((item for item in self.materials if item.name == style_name), None) @@ -213,12 +235,6 @@ class RadianceExporterProperties(PropertyGroup): item.subcategory = "" item.is_mapped = False - use_json_file: BoolProperty( - name="Upload JSON", - description="Toggle between uploading a JSON file and using in-UI material mapping", - default=False, - ) - is_exporting: bpy.props.BoolProperty( name="Is Exporting", description="Whether the OBJ export is in progress", default=False ) @@ -237,6 +253,7 @@ class RadianceExporterProperties(PropertyGroup): ("Plant", "Plant", ""), ("Exterior", "Exterior", ""), ("Color Swatch", "Color Swatch", ""), + ("Glass", "Glass", ""), ] def update_material_mapping(self, context): @@ -269,14 +286,13 @@ class RadianceExporterProperties(PropertyGroup): should_load_from_memory: BoolProperty( name="Load from Memory", default=False, - description="Use IFC file currently loaded in Bonsai", ) radiance_resolution_x: IntProperty( - name="X", description="Horizontal resolution of the output image", default=1920, min=1 + name="X", description="Horizontal resolution of the output image", default=1920, min=1, update=update_resolution ) radiance_resolution_y: IntProperty( - name="Y", description="Vertical resolution of the output image", default=1080, min=1 + name="Y", description="Vertical resolution of the output image", default=1080, min=1, update=update_resolution ) output_dir: StringProperty( name="Output Directory", @@ -292,13 +308,6 @@ class RadianceExporterProperties(PropertyGroup): subtype="FILE_PATH", update=lambda self, context: self.update_ifc_file(context), ) - json_file: StringProperty( - name="JSON File", - description="Path to the JSON file", - default="", - subtype="FILE_PATH", - update=lambda self, context: self.update_json_file(context), - ) radiance_quality: EnumProperty( name="Quality", @@ -350,6 +359,17 @@ class RadianceExporterProperties(PropertyGroup): default="Noon", ) + use_active_camera: BoolProperty( + name="Use Active Camera", description="Use the active camera in the scene", default=True + ) + + selected_camera: PointerProperty( + type=bpy.types.Object, + name="Camera", + description="Select a camera to use for rendering", + poll=lambda self, object: object.type == "CAMERA", + ) + class BIMSolarProperties(PropertyGroup): sites: EnumProperty(items=get_sites, name="Sites") @@ -364,6 +384,9 @@ class BIMSolarProperties(PropertyGroup): sun_position: FloatVectorProperty(name="Sun Position", subtype="XYZ", default=(0, 0, 0)) sun_path_origin: FloatVectorProperty(name="Sun Path Origin", subtype="XYZ", default=(0, 0, 0)) sun_path_size: FloatProperty(name="Sun Path Size", min=0.1, default=50, update=update_sun_path_size) + azimuth: FloatProperty(name="Azimuth") + elevation: FloatProperty(name="Elevation") + UTC_zone: FloatProperty(name="UTC Zone") display_shadows: BoolProperty( name="Display Shadows", default=False, diff --git a/src/bonsai/bonsai/bim/module/light/spectraldb.json b/src/bonsai/bonsai/bim/module/light/spectraldb.json index 812cf88f7c..82ea07d0df 100644 --- a/src/bonsai/bonsai/bim/module/light/spectraldb.json +++ b/src/bonsai/bonsai/bim/module/light/spectraldb.json @@ -1,4 +1,7 @@ { + "Glass": { + "Clear Glass": "void BRTDfunc clear_glass\n10\n sr_clear_r sr_clear_g sr_clear_b\n st_clear_r st_clear_g st_clear_b\n 0 0 0\n glaze1.cal\n0\n19\n 0 0 0\n 0 0 0\n 0 0 0\n 1\n 0.074 0.077 0.079\n 0.074 0.077 0.079\n 0.862 0.890 0.886\n" + }, "Wall": { "White Painted Room Walls": "void plastic white_painted_room_walls \n0\n0\n5 0.8316 0.8116 0.7226 0.0036 0.2", "White Painted Corridor Walls": "void plastic white_painted_corridor_walls \n0\n0\n5 0.8143 0.7984 0.715 0.0039 0.2", diff --git a/src/bonsai/bonsai/bim/module/light/ui.py b/src/bonsai/bonsai/bim/module/light/ui.py index b13410c3d7..3a456badce 100644 --- a/src/bonsai/bonsai/bim/module/light/ui.py +++ b/src/bonsai/bonsai/bim/module/light/ui.py @@ -50,43 +50,52 @@ class BIM_PT_radiance_exporter(bpy.types.Panel): row.prop(props, "output_dir") row = layout.row() - layout.prop(props, "use_json_file") + layout.label(text="Info: Unmapped materials default to white") + row = layout.row() + row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index") + row.operator("radiance.open_spectraldb", text="", icon="WORLD") # Globe icon + if len(props.materials) > 0: + col = layout.column(align=True) + col.prop(props, "category") + if props.category: + col.prop(props, "subcategory") - if props.use_json_file: - row = layout.row() - row.prop(props, "json_file") + if props.active_material_index >= 0 and props.active_material_index < len(props.materials): + active_material = props.materials[props.active_material_index] + if active_material.category and active_material.subcategory: + layout.label( + text=f"Mapped: {active_material.name} to {active_material.category} - {active_material.subcategory}" + ) + else: + layout.label(text=f"Select category and subcategory for: {active_material.name}") - if not props.use_json_file: - row = layout.row() - layout.label(text="Info: Unmapped materials default to white") - row = layout.row() - row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index") - - if len(props.materials) > 0: - col = layout.column(align=True) - col.prop(props, "category") - if props.category: - col.prop(props, "subcategory") - - if props.active_material_index >= 0 and props.active_material_index < len(props.materials): - active_material = props.materials[props.active_material_index] - if active_material.category and active_material.subcategory: - layout.label( - text=f"Mapped: {active_material.name} to {active_material.category} - {active_material.subcategory}" - ) - else: - layout.label(text=f"Select category and subcategory for: {active_material.name}") - - row = layout.row() - row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials") + row = layout.row() + row.operator("radiance.import_material_mappings", text="Import Mappings", icon="IMPORT") + row.operator("radiance.export_material_mappings", text="Export Mappings", icon="EXPORT") + row = layout.row() + row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials") layout.separator() - row = layout.row() - row.label(text="Resolution") - row.prop(props, "radiance_resolution_x", text="X") row = layout.row() - row.label(text="") + layout.label(text="Step 1: Export geometry for simulation") + row = layout.row() + row.operator("export_scene.radiance", text="Export Geometry for Simulation") + + layout.separator() + + box = layout.box() + box.label(text="Camera Settings") + row = box.row() + row.prop(props, "use_active_camera") + if not props.use_active_camera: + row = box.row() + row.prop(props, "selected_camera") + row.operator("radiance.select_camera", text="", icon="EYEDROPPER") + + row = box.row(align=True) + row.label(text="Resolution") + row.prop(props, "radiance_resolution_x", text="X") row.prop(props, "radiance_resolution_y", text="Y") row = layout.row() @@ -115,8 +124,7 @@ class BIM_PT_radiance_exporter(bpy.types.Panel): row.prop(props, "choose_hdr_image") row = layout.row() - row.operator("export_scene.radiance", text="Export Geometry for Simulation") - + layout.label(text="Step 2: Run the simulation") row = layout.row() row.operator("render_scene.radiance", text="Radiance Render") row.enabled = not props.is_exporting diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index dfb60f11a2..02bdadb884 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -32,43 +32,47 @@ from bonsai.bim.module.material.prop import purge as material_prop_purge from bonsai.bim.ifc import IfcStore -class LoadMaterials(bpy.types.Operator, tool.Ifc.Operator): +class LoadMaterials(bpy.types.Operator): bl_idname = "bim.load_materials" bl_label = "Load Materials" bl_description = "Display list of named materials" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): core.load_materials(tool.Material, context.scene.BIMMaterialProperties.material_type) + return {"FINISHED"} -class DisableEditingMaterials(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingMaterials(bpy.types.Operator): bl_idname = "bim.disable_editing_materials" bl_label = "Disable Editing Materials" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): core.disable_editing_materials(tool.Material) + return {"FINISHED"} -class SelectByMaterial(bpy.types.Operator, tool.Ifc.Operator): +class SelectByMaterial(bpy.types.Operator): bl_idname = "bim.select_by_material" bl_label = "Select By Material" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.select_by_material(tool.Material, tool.Spatial, material=tool.Ifc.get().by_id(self.material)) + return {"FINISHED"} -class EnableEditingMaterial(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingMaterial(bpy.types.Operator): bl_idname = "bim.enable_editing_material" bl_label = "Enable Editing Material" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.enable_editing_material(tool.Material, material=tool.Ifc.get().by_id(self.material)) + return {"FINISHED"} class EditMaterial(bpy.types.Operator, tool.Ifc.Operator): @@ -81,14 +85,15 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator): core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material)) -class DisableEditingMaterial(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingMaterial(bpy.types.Operator): bl_idname = "bim.disable_editing_material" bl_label = "Disable Editing Material" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.disable_editing_material(tool.Material) + return {"FINISHED"} class AssignParameterizedProfile(bpy.types.Operator, tool.Ifc.Operator): @@ -156,6 +161,7 @@ class DuplicateMaterial(bpy.types.Operator, tool.Ifc.Operator): class AddMaterialSet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_material_set" bl_label = "Add Material Set" + bl_description = "Create a new material set, will use a default material/profile as a placeholder" bl_options = {"REGISTER", "UNDO"} set_type: bpy.props.StringProperty() @@ -775,30 +781,31 @@ class ContractMaterialCategory(bpy.types.Operator): return {"FINISHED"} -class EnableEditingMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingMaterialStyle(bpy.types.Operator): bl_idname = "bim.enable_editing_material_style" bl_label = "Enable Editing Material Style" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): props = bpy.context.scene.BIMMaterialProperties props.active_material_id = self.material props.editing_material_type = "STYLE" # set already applied style and context if possible if not 0 <= props.active_material_index < len(props.materials): - return + return {"FINISHED"} material = tool.Ifc.get().by_id(props.materials[props.active_material_index].ifc_definition_id) if not material.HasRepresentation: - return + return {"FINISHED"} rep = material.HasRepresentation[0].Representations[0] # IfcStyledRepresentation props.contexts = str(rep.ContextOfItems.id()) style = rep.Items[0].Styles[0] if style.Name: # props.styles only has named styles props.styles = str(rep.Items[0].Styles[0].id()) + return {"FINISHED"} class EditMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index ca91b7aacc..6951ef2c67 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -63,7 +63,9 @@ classes = ( product.EnableAddType, product.LoadTypeThumbnails, product.MirrorElements, + product.SetActiveType, workspace.Hotkey, + workspace.BIM_MT_add_representation_item, wall.AlignWall, wall.ChangeExtrusionDepth, wall.ChangeExtrusionXAngle, @@ -127,6 +129,7 @@ classes = ( prop.BIMDoorProperties, prop.BIMRailingProperties, prop.BIMRoofProperties, + prop.BIMPolylineProperties, ui.BIM_PT_array, ui.BIM_PT_stair, ui.BIM_PT_sverchok, @@ -134,9 +137,9 @@ classes = ( ui.BIM_PT_door, ui.BIM_PT_railing, ui.BIM_PT_roof, + ui.BIM_MT_type_manager_menu, ui.LaunchTypeManager, - ui.BIM_MT_model, - ui.BIM_PT_Grids, + ui.BIM_MT_elements, grid.BIM_OT_add_object, stair.BIM_OT_add_stair, stair.AddStair, @@ -204,6 +207,7 @@ def register(): bpy.utils.register_tool(workspace.CableTool, after={"bim.cable_carrier_tool"}, separator=False, group=False) bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties) + bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties) bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties) bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties) bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties) @@ -212,12 +216,12 @@ def register(): bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties) bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties) - bpy.types.VIEW3D_MT_mesh_add.append(ui.add_mesh_object_menu) - bpy.types.VIEW3D_MT_add.append(ui.add_menu) + bpy.types.VIEW3D_MT_add.prepend(ui.add_menu) bpy.app.handlers.load_post.append(handler.load_post) workspace.load_custom_icons() + def unregister(): if not bpy.app.background: bpy.utils.unregister_tool(workspace.WallTool) @@ -231,7 +235,9 @@ def unregister(): bpy.utils.unregister_tool(workspace.CableCarrierTool) bpy.utils.unregister_tool(workspace.CableTool) bpy.utils.unregister_tool(workspace.BimTool) + del bpy.types.Scene.BIMModelProperties + del bpy.types.Scene.BIMPolylineProperties del bpy.types.Object.BIMArrayProperties del bpy.types.Object.BIMStairProperties del bpy.types.Object.BIMSverchokProperties @@ -239,7 +245,8 @@ def unregister(): del bpy.types.Object.BIMDoorProperties del bpy.types.Object.BIMRailingProperties del bpy.types.Object.BIMRoofProperties + bpy.app.handlers.load_post.remove(handler.load_post) - bpy.types.VIEW3D_MT_mesh_add.remove(ui.add_mesh_object_menu) bpy.types.VIEW3D_MT_add.remove(ui.add_menu) - workspace.unload_custom_icons() \ No newline at end of file + + workspace.unload_custom_icons() diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 24aeb2947a..7e91db2804 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -29,6 +29,7 @@ from mathutils import Vector, Matrix class AddArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_array" bl_label = "Add Array" + bl_description = "Add Bonsai parametric array to the active IFC element" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py index 16ea184fb1..812ed34c1c 100644 --- a/src/bonsai/bonsai/bim/module/model/data.py +++ b/src/bonsai/bonsai/bim/module/model/data.py @@ -25,10 +25,12 @@ import ifcopenshell.util.schema 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 def refresh(): AuthoringData.is_loaded = False + ItemData.is_loaded = False ArrayData.is_loaded = False StairData.is_loaded = False SverchokData.is_loaded = False @@ -55,6 +57,7 @@ class AuthoringData: cls.data["ifc_element_type"] = cls.ifc_element_type cls.data["ifc_classes"] = cls.ifc_classes() cls.data["relating_type_id"] = cls.relating_type_id() # only after .ifc_classes() + cls.data["relating_type_name"] = cls.relating_type_name() # only after .relating_type_id() cls.data["predefined_type"] = cls.predefined_type() # only after .relating_type_id() cls.data["type_class"] = cls.type_class() @@ -75,6 +78,7 @@ class AuthoringData: cls.data["active_representation_type"] = cls.active_representation_type() cls.data["boundary_class"] = cls.boundary_class() cls.data["selected_material_usages"] = cls.selected_material_usages() + cls.data["type_template"] = cls.type_template() @classmethod def default_container(cls) -> str | None: @@ -138,6 +142,67 @@ class AuthoringData: element = tool.Ifc.get().by_id(int(relating_type_id)) return cls.type_thumbnails.get(element.id(), None) or 0 + @classmethod + def type_template(cls): + type_class = cls.props.type_class + templates = [ + ( + "MESH", + "Custom Mesh", + "Use as a representation currently active object mesh or default cube if no object selected", + ), + ( + "LAYERSET_AXIS2", + "Vertical Layers", + "For objects similar to walls, will automatically add IfcMaterialLayerSet", + ), + ( + "LAYERSET_AXIS3", + "Horizontal Layers", + "For objects similar to slabs, will automatically add IfcMaterialLayerSet", + ), + ( + "PROFILESET", + "Extruded Profile", + "Create profile type object, automatically defines IfcMaterialProfileSet with the first profile from library", + ), + ("EMPTY", "Non-Geometric Type", "Start with an empty object"), + ] + if not type_class: + pass + elif type_class in ("IfcWindowType", "IfcWindowStyle"): + templates.append(("WINDOW", "Window", "Parametric window")) + elif type_class in ("IfcDoorType", "IfcDoorStyle"): + templates.append(("DOOR", "Door", "Parametric door")) + elif type_class in ("IfcStairType", "IfcStairFlightType"): + templates.append(("STAIR", "Stair", "Parametric stair")) + elif type_class == "IfcRailingType": + templates.append(("RAILING", "Railing", "Parametric railing")) + elif type_class == "IfcRoofType": + templates.append(("Roof", "Roof", "Parametric roof")) + + templates.extend( + ( + ( + "FLOW_SEGMENT_RECTANGULAR", + "Rectangular Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), + ( + "FLOW_SEGMENT_CIRCULAR", + "Circular Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), + ( + "FLOW_SEGMENT_CIRCULAR_HOLLOW", + "Circular Hollow Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), + ) + ) + + return templates + @classmethod def total_types(cls): type_class = cls.props.type_class @@ -165,7 +230,7 @@ class AuthoringData: if not type_class: return [] results = [] - elements = sorted(tool.Ifc.get().by_type(type_class), key=lambda e: e.Name or "Unnamed") + elements = natsorted(tool.Ifc.get().by_type(type_class), key=lambda e: e.Name or "Unnamed") 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) @@ -256,11 +321,16 @@ class AuthoringData: if not ifc_class and ifc_classes: ifc_class = ifc_classes[0][0] if ifc_class: - elements = sorted(tool.Ifc.get().by_type(ifc_class), key=lambda s: (s.Name or "Unnamed").lower()) + elements = natsorted(tool.Ifc.get().by_type(ifc_class), key=lambda s: (s.Name or "Unnamed").lower()) results.extend(elements) return [(str(e.id()), e.Name or "Unnamed", e.Description or "") for e in results] return [] + @classmethod + def relating_type_name(cls): + if relating_type_id := cls.props.relating_type_id: + return tool.Ifc.get().by_id(int(relating_type_id)).Name or "Unnamed" + @classmethod def predefined_type(cls): relating_type_id = tool.Blender.get_enum_safe(cls.props, "relating_type_id") @@ -557,3 +627,27 @@ class RoofData: general_params[prop_readable_name] = prop_value return general_params + + +class ItemData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.is_loaded = True + cls.data = {} + cls.data["representation_identifier"] = cls.representation_identifier() + cls.data["representation_type"] = cls.representation_type() + + @classmethod + def representation_identifier(cls): + props = bpy.context.scene.BIMGeometryProperties + rep = tool.Geometry.get_active_representation(props.representation_obj) + return rep.RepresentationIdentifier + + @classmethod + def representation_type(cls): + props = bpy.context.scene.BIMGeometryProperties + rep = tool.Geometry.get_active_representation(props.representation_obj) + return rep.RepresentationType diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index e2eae33d3e..57dd3bf2e7 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -27,6 +27,7 @@ import math from bpy_extras import view3d_utils from mathutils import Vector, Matrix 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 @@ -301,20 +302,27 @@ class ProfileDecorator: class PolylineDecorator: is_installed = False handlers = [] - mouse_pos = None - input_panel = None + event = None input_type = None + input_ui = None angle_snap_mat = None angle_snap_loc = None use_default_container = False + instructions = None + snap_info = None + tool_state = None @classmethod def install(cls, context): if cls.is_installed: cls.uninstall() handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_input_panel, (context,), "WINDOW", "POST_PIXEL")) + 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.draw_product_preview, (context,), "WINDOW", "POST_VIEW") + ) cls.handlers.append(SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")) cls.is_installed = True @@ -328,183 +336,27 @@ class PolylineDecorator: cls.is_installed = False @classmethod - def set_mouse_position(cls, event): - cls.mouse_pos = event.mouse_region_x, event.mouse_region_y + def update(cls, event, tool_state, input_ui, snapping_point): + cls.event = event + cls.tool_state = tool_state + cls.input_ui = input_ui @classmethod - def set_input_panel(cls, input_panel, input_type): - cls.input_panel = input_panel - cls.input_type = input_type + def set_input_ui(cls, input_ui): + cls.input_ui = input_ui @classmethod def set_angle_axis_line(cls, start, end): cls.axis_start = start cls.axis_end = end - @classmethod - def set_axis_rectangle(cls, corners): - cls.axis_rectangle = [*corners] + # @classmethod + # def set_axis_rectangle(cls, corners): + # cls.axis_rectangle = [*corners] @classmethod - def set_use_default_container(cls, value=False): - cls.use_default_container = value - - @classmethod - def set_plane(cls, plane_origin, plane_normal): - cls.plane_origin = plane_origin - cls.plane_normal = plane_normal - - @classmethod - def calculate_distance_and_angle(cls, context, is_input_on): - - try: - polyline_data = context.scene.BIMModelProperties.polyline_point - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z - last_point_data = polyline_data[len(polyline_data) - 1] - except: - last_point_data = None - second_to_last_point_data = None - default_container_elevation = 0 - - snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] - - if last_point_data: - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) - else: - last_point = Vector((0, 0, 0)) - - if is_input_on: - snap_vector = Vector( - (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), default_container_elevation) - ) - else: - if cls.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)) - - second_to_last_point = None - if len(polyline_data) > 1: - second_to_last_point_data = polyline_data[len(polyline_data) - 2] - second_to_last_point = Vector( - (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) - ) - 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)) - - distance = (snap_vector - last_point).length - if distance > 0: - print(snap_vector, last_point, second_to_last_point) - angle = tool.Cad.angle_3_vectors(snap_vector, last_point, second_to_last_point, degrees=True) - print("A", angle) - if cls.input_panel: - cls.input_panel["X"] = str(round(snap_vector.x, 4)) - cls.input_panel["Y"] = str(round(snap_vector.y, 4)) - if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(snap_vector.z, 4)) - cls.input_panel["D"] = str(round(distance, 4)) - cls.input_panel["A"] = str(round(angle, 4)) - - return cls.input_panel - - return cls.input_panel - - @classmethod - def calculate_area(cls, context): - try: - polyline_data = context.scene.BIMModelProperties.polyline_point - except: - return cls.input_panel - - if len(polyline_data) < 3: - return cls.input_panel - - points = [] - for data in polyline_data: - points.append(Vector((data.x, data.y, data.z))) - - if points[0] == points[-1]: - points = points[1:] - - # TODO move this to CAD - # Calculate the normal vector of the plane formed by the first three vertices - v1, v2, v3 = points[:3] - normal = (v2 - v1).cross(v3 - v1).normalized() - - # Check if all points are coplanar - is_coplanar = True - tolerance = 1e-6 # Adjust this value as needed - for v in points: - if abs((v - v1).dot(normal)) > tolerance: - is_coplanar = False - - if is_coplanar: - area = 0 - for i in range(len(points)): - j = (i + 1) % len(points) - area += points[i].cross(points[j]).dot(normal) - - area = abs(area) / 2 - else: - area = 0 - - if "AREA" in list(cls.input_panel.keys()): - cls.input_panel["AREA"] = str(round(area, 4)) - return cls.input_panel - - - @classmethod - def calculate_x_y_and_z(cls, context): - try: - polyline_data = context.scene.BIMModelProperties.polyline_point - last_point_data = polyline_data[len(polyline_data) - 1] - except: - return - - snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] - snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) - second_to_last_point = None - if len(polyline_data) > 1: - second_to_last_point_data = polyline_data[len(polyline_data) - 2] - second_to_last_point = Vector( - (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) - ) - 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 - 10, last_point.y, last_point.z)) - - distance = float(cls.input_panel["D"]) - - if distance < 0 or distance > 0: - angle_rad = radians(180 - float(cls.input_panel["A"])) - ref_vec = second_to_last_point - last_point - dir_vec = last_point - snap_vector - - rot_axis = ref_vec.cross(dir_vec) - rot_axis.normalize() - rot_axis = Vector((abs(rot_axis.x), abs(rot_axis.y), abs(rot_axis.z))) - - rot_mat = Matrix.Rotation(angle_rad, 3, rot_axis) - - ref_vec.normalize() - coords = ((ref_vec @ rot_mat) * distance) + last_point - - x = coords[0] - y = coords[1] - z = coords[2] - if cls.input_panel: - cls.input_panel["X"] = str(round(x, 4)) - cls.input_panel["Y"] = str(round(y, 4)) - if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(z, 4)) - - return cls.input_panel - - return cls.input_panel + def set_tool_state(cls, tool_state): + cls.tool_state = tool_state def draw_batch(self, shader_type, content_pos, color, indices=None): shader = self.line_shader if shader_type == "LINES" else self.shader @@ -512,87 +364,176 @@ class PolylineDecorator: shader.uniform_float("color", color) batch.draw(shader) - def draw_input_panel(self, context): - texts = {"D": "Distance:", "A": "Angle:", "X": "X coord:", "Y": "Y coord:", "Z": "Z coord:", "AREA": "Area:"} + def draw_product_preview(self, context): + 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", 0.5) + decorator_color = self.addon_prefs.decorator_color_special + polyline = context.scene.BIMPolylineProperties.polyline_point + prop = context.scene.BIMPolylineProperties.product_preview - unit_system = tool.Drawing.get_unit_system() - if unit_system == "IMPERIAL": - precision = context.scene.DocProperties.imperial_precision - factor = 3.28084 - else: - precision = None - factor = 1 + points = [] + edges = [] + + for i in range(len(prop)): + points.append(Vector((prop[i].x, prop[i].y, prop[i].z))) + + n = len(points) // 2 + bottom_loop = [[i, (i + 1) % (n)] for i in range(n)] + edges.extend(bottom_loop) + upper_loop = [[i + n for i in edges] for edges in bottom_loop] + edges.extend(upper_loop) + connections = [[i, j] for i, j in zip(range(n), range(n, n * 2))] + edges.extend(connections) + + if len(points) > 1: + self.draw_batch("LINES", points, decorator_color, edges) + + def draw_input_ui(self, context): + texts = { + "D": "Distance: ", + "A": "Angle: ", + "X": "X coord: ", + "Y": "Y coord: ", + "Z": "Z coord:", + "AREA": "Area: ", + } + try: + mouse_pos = self.event.mouse_region_x, self.event.mouse_region_y + except: + mouse_pos = (None, None) self.addon_prefs = tool.Blender.get_addon_preferences() self.font_id = 0 - blf.size(self.font_id, 12) + font_size = tool.Blender.scale_font_size(12) + blf.size(self.font_id, font_size) blf.enable(self.font_id, blf.SHADOW) blf.shadow(self.font_id, 6, 0, 0, 0, 1) color = self.addon_prefs.decorations_colour color_highlight = self.addon_prefs.decorator_color_special offset = 20 new_line = 20 - for i, (key, value) in enumerate(self.input_panel.items()): + for i, (key, field_name) in enumerate(texts.items()): - if key != "A" and key != self.input_type: - value = float(value) - if context.scene.unit_settings.length_unit == 'MILLIMETERS': - value = value * 1000 - formatted_value = format_distance(value*factor, precision=precision, suppress_zero_inches=True, in_unit_length=True) + if self.tool_state and key != self.tool_state.input_type: + formatted_value = self.input_ui.get_formatted_value(key) else: - formatted_value = value + formatted_value = self.input_ui.get_text_value(key) - if key not in list(texts.keys()): + if formatted_value is None: continue - if key == self.input_type: + if self.tool_state and key == self.tool_state.input_type: blf.color(self.font_id, *color_highlight) else: blf.color(self.font_id, *color) - blf.position(self.font_id, self.mouse_pos[0] + offset, self.mouse_pos[1] - (new_line * i), 0) - blf.draw(self.font_id, texts[key] + formatted_value) + blf.position(self.font_id, mouse_pos[0] + offset, mouse_pos[1] - (new_line * i), 0) + blf.draw(self.font_id, field_name + formatted_value) + blf.disable(self.font_id, blf.SHADOW) + + def draw_text_background(self, context, coords_dim, text_dim): + padding = 5 + theme = context.preferences.themes.items()[0][1] + color = (*theme.user_interface.wcol_menu_back.inner[:3], 0.5) # unwrap color values and adds alpha + top_left = (coords_dim[0] - padding, coords_dim[1] + text_dim[1] + padding) + bottom_left = (coords_dim[0] - padding, coords_dim[1] - padding) + top_right = (coords_dim[0] + text_dim[0] + padding, coords_dim[1] + text_dim[1] + padding) + bottom_right = (coords_dim[0] + text_dim[0] + padding, coords_dim[1] - padding) + + verts = [top_left, bottom_left, top_right, bottom_right] + gpu.state.blend_set("ALPHA") + self.draw_batch("TRIS", verts, color, [(0, 1, 2), (1, 2, 3)]) def draw_measurements(self, context): region = context.region rv3d = region.data - measurement_prop = context.scene.BIMModelProperties.polyline_measurement - + measurement_prop = context.scene.BIMPolylineProperties.polyline_measurement self.addon_prefs = tool.Blender.get_addon_preferences() - self.font_id = 0 - blf.size(self.font_id, 12) + self.font_id = 1 + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + font_size = tool.Blender.scale_font_size(12) + blf.size(self.font_id, font_size) blf.enable(self.font_id, blf.SHADOW) blf.shadow(self.font_id, 6, 0, 0, 0, 1) color = self.addon_prefs.decorations_colour - blf.color(self.font_id, *color) for i in range(len(measurement_prop)): if i == 0: continue - pos_dim = (Vector(measurement_prop[i].position) + Vector(measurement_prop[i-1].position)) / 2 + pos_dim = (Vector(measurement_prop[i].position) + Vector(measurement_prop[i - 1].position)) / 2 coords_dim = view3d_utils.location_3d_to_region_2d(region, rv3d, pos_dim) - unit_system = tool.Drawing.get_unit_system() - if unit_system == "IMPERIAL": - precision = context.scene.DocProperties.imperial_precision - factor = 3.28084 - else: - precision = None - factor = 1 + formatted_value = measurement_prop[i].dim - value = measurement_prop[i].dim - value = float(value) - if context.scene.unit_settings.length_unit == 'MILLIMETERS': - value = value * 1000 - formatted_value = format_distance(value*factor, precision=precision, suppress_zero_inches=True, in_unit_length=True) - blf.position(self.font_id, coords_dim[0], coords_dim[1], 0) - blf.draw(self.font_id, "d: " + formatted_value) + text = "d: " + formatted_value + text_dim = blf.dimensions(self.font_id, text) + self.draw_text_background(context, coords_dim, text_dim) + blf.draw(self.font_id, text) - pos_angle = measurement_prop[i-1].position + if i == 1: + continue + pos_angle = measurement_prop[i - 1].position coords_angle = view3d_utils.location_3d_to_region_2d(region, rv3d, pos_angle) blf.position(self.font_id, coords_angle[0], coords_angle[1], 0) - blf.draw(self.font_id, "a: " + measurement_prop[i].angle) + text = "a: " + measurement_prop[i].angle + text_dim = blf.dimensions(self.font_id, text) + self.draw_text_background(context, coords_angle, text_dim) + blf.draw(self.font_id, text) + blf.disable(self.font_id, blf.SHADOW) + + def draw_snap_point(self, context): + 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", 1.0) + theme = context.preferences.themes.items()[0][1] + decorator_color_object_active = (*theme.view_3d.object_active, 1) # unwrap color values and adds alpha=1 + + region = context.region + rv3d = region.data + + snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] + mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + + try: + snap_prop = context.scene.BIMPolylineProperties.snap_mouse_ref[0] + mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + except: + pass + + coords = view3d_utils.location_3d_to_region_2d(region, rv3d, mouse_point) + padding = 6 + verts = [] + edges = [] + if snap_prop.snap_type in ["Edge", "Vertex"]: + p1 = (coords[0] - padding, coords[1] + padding) + p2 = (coords[0] + padding, coords[1] + padding) + p3 = (coords[0] + padding, coords[1] - padding) + p4 = (coords[0] - padding, coords[1] - padding) + verts = [p1, p2, p3, p4] + if snap_prop.snap_type == "Edge": + edges = [[0, 1], [1, 3], [3, 2], [2, 0]] + else: + edges = [[0, 1], [1, 2], [2, 3], [3, 0]] + elif snap_prop.snap_type == "Edge Center": + p1 = (coords[0], coords[1] + padding) + p2 = (coords[0] + padding, coords[1] - padding) + p3 = (coords[0] - padding, coords[1] - padding) + verts = [p1, p2, p3] + edges = [[0, 1], [1, 2], [2, 0]] + elif snap_prop.snap_type == "Face": + draw_circle_2d(coords, decorator_color_object_active, padding) + return + else: + return + + self.draw_batch("LINES", verts, decorator_color_object_active, edges) def __call__(self, context): @@ -603,6 +544,11 @@ class PolylineDecorator: decorator_color_error = self.addon_prefs.decorator_color_error decorator_color_unselected = self.addon_prefs.decorator_color_unselected decorator_color_background = self.addon_prefs.decorator_color_background + theme = context.preferences.themes.items()[0][1] + decorator_color_object_active = (*theme.view_3d.object_active, 1) # unwrap color values and adds alpha=1 + decorator_color_x_axis = (*theme.user_interface.axis_x, 1) + decorator_color_y_axis = (*theme.user_interface.axis_y, 1) + decorator_color_z_axis = (*theme.user_interface.axis_z, 1) gpu.state.blend_set("ALPHA") self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") @@ -612,29 +558,15 @@ class PolylineDecorator: # general shader self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") - gpu.state.point_size_set(10) + gpu.state.point_size_set(6) - snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] + snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] # Point related to the mouse mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))] - try: - snap_ref = context.scene.BIMModelProperties.snap_mouse_ref[0] - ref_point = [Vector((snap_ref.x, snap_ref.y, snap_ref.z))] - except: - ref_point = None - - if snap_prop.snap_type in ["Face", "Plane"]: - self.draw_batch("POINTS", mouse_point, decorator_color_unselected) - else: - self.draw_batch("POINTS", mouse_point, (1.0, 0.6, 0.0, 1.0)) - - if ref_point: - self.draw_batch("POINTS", ref_point, (1.0, 0.6, 0.0, 1.0)) - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z projection_point = [] - if self.use_default_container: + if self.tool_state and self.tool_state.use_default_container: # When a point is above the plane it projects the point # to the plane and creates a line if snap_prop.snap_type != "Plane" and snap_prop.z != 0: @@ -642,10 +574,10 @@ class PolylineDecorator: projection_point = [Vector((snap_prop.x, snap_prop.y, default_container_elevation))] self.draw_batch("POINTS", projection_point, decorator_color_unselected) edges = [[0, 1]] - self.draw_batch("LINES", mouse_point + projection_point, (1.0, 0.6, 0.0, 1.0), edges) + self.draw_batch("LINES", mouse_point + projection_point, decorator_color_unselected, edges) - # Polyline with selected points - polyline_data = context.scene.BIMModelProperties.polyline_point + # Create polyline with selected points + polyline_data = context.scene.BIMPolylineProperties.polyline_point polyline_points = [] polyline_edges = [] for point_prop in polyline_data: @@ -655,33 +587,54 @@ class PolylineDecorator: for i in range(len(polyline_points) - 1): polyline_edges.append([i, i + 1]) - self.line_shader.uniform_float("lineWidth", 2.0) - self.draw_batch("POINTS", polyline_points, decorator_color_selected) - if len(polyline_points) > 1: - self.draw_batch("LINES", polyline_points, decorator_color_selected, polyline_edges) + # Line for angle axis snap + if snap_prop.snap_type == "Axis": + axis_color = decorator_color + if math.isclose(self.axis_start.y, self.axis_end.y, rel_tol=0.001) and math.isclose( + self.axis_start.z, self.axis_end.z, rel_tol=0.001 + ): + axis_color = decorator_color_x_axis + if math.isclose(self.axis_start.x, self.axis_end.x, rel_tol=0.001) and math.isclose( + self.axis_start.z, self.axis_end.z, rel_tol=0.001 + ): + axis_color = decorator_color_y_axis + if math.isclose(self.axis_start.x, self.axis_end.x, rel_tol=0.001) and math.isclose( + self.axis_start.y, self.axis_end.y, rel_tol=0.001 + ): + axis_color = decorator_color_z_axis + + self.line_shader.uniform_float("lineWidth", 0.75) + self.draw_batch("LINES", [self.axis_start, self.axis_end], axis_color, [(0, 1)]) + + # try: + # self.draw_batch("TRIS", self.axis_rectangle, (1, 1, 1, 0.1), [(0, 1, 3), (0, 2, 3)]) + # except: + # pass + + # Area highlight + # if "AREA" in list(self.input_panel.keys()): # TODO Change to input_ui + # if self.input_panel["AREA"] and float(self.input_panel["AREA"]) > 0: # TODO Change to input_ui + # edges = [] + # for i in range(1, len(polyline_points) - 1): + # edges.append((0, i, i + 1)) + # self.draw_batch("TRIS", polyline_points, (0, 1, 0, 0.1), edges) + + # Mouse points + print("DECO", snap_prop.snap_type) + if snap_prop.snap_type in ["Plane", "Axis", "Mix"]: + self.draw_batch("POINTS", mouse_point, decorator_color_unselected) # Line between last polyline point and mouse + self.line_shader.uniform_float("lineWidth", 2.0) edges = [[0, 1]] if polyline_points: if snap_prop.snap_type != "Plane" and projection_point: - self.draw_batch("LINES", [polyline_points[-1]] + projection_point, decorator_color_unselected, edges) + self.draw_batch("LINES", [polyline_points[-1]] + projection_point, decorator_color_selected, edges) else: - self.draw_batch("LINES", [polyline_points[-1]] + mouse_point, decorator_color_unselected, edges) + self.draw_batch("LINES", [polyline_points[-1]] + mouse_point, decorator_color_selected, edges) - # Line for angle axis snap - if snap_prop.snap_type == "Axis": - self.line_shader.uniform_float("lineWidth", 0.75) - self.draw_batch("LINES", [self.axis_start, self.axis_end], decorator_color_unselected, [(0, 1)]) - - try: - self.draw_batch("TRIS", self.axis_rectangle, (1, 1, 1, 0.1), [(0, 1, 3), (0, 2, 3)]) - except: - pass - - # Area highlight - if "AREA" in list(self.input_panel.keys()): - if self.input_panel["AREA"] and float(self.input_panel["AREA"]) > 0: - edges = [] - for i in range(1, len(polyline_points) - 1): - edges.append((0, i, i + 1)) - self.draw_batch("TRIS", polyline_points, (0, 1, 0, 0.1), edges) + # Draw polyline with selected points + self.line_shader.uniform_float("lineWidth", 2.0) + self.draw_batch("POINTS", polyline_points, decorator_color_unselected) + if len(polyline_points) > 1: + self.draw_batch("LINES", polyline_points, decorator_color_unselected, polyline_edges) diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 4e9a826104..0269dbb7cf 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -40,7 +40,8 @@ import json import collections -def update_door_modifier_representation(context: bpy.types.Context, obj: bpy.types.Object) -> None: +def update_door_modifier_representation(context: bpy.types.Context) -> None: + obj = context.active_object props = obj.BIMDoorProperties element = tool.Ifc.get_entity(obj) ifc_file = tool.Ifc.get() @@ -533,11 +534,11 @@ class BIM_OT_add_door(bpy.types.Operator, tool.Ifc.Operator): class AddDoor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_door" bl_label = "Add Door" + bl_description = "Add Bonsai parametric door to the active IFC element" bl_options = {"REGISTER"} - obj: bpy.props.StringProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMDoorProperties @@ -558,7 +559,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))}, ) - update_door_modifier_representation(context, obj) + update_door_modifier_representation(context) return {"FINISHED"} @@ -612,7 +613,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - update_door_modifier_representation(context, obj) + update_door_modifier_representation(context) pset = tool.Pset.get_element_pset(element, "BBIM_Door") door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list)) diff --git a/src/bonsai/bonsai/bim/module/model/grid.py b/src/bonsai/bonsai/bim/module/model/grid.py index 8f7123c8fd..2d4cdfa501 100644 --- a/src/bonsai/bonsai/bim/module/model/grid.py +++ b/src/bonsai/bonsai/bim/module/model/grid.py @@ -19,6 +19,7 @@ import bpy import ifcopenshell.api import bonsai.tool as tool +import bonsai.core.root from bpy.types import Operator from bpy.props import FloatProperty, IntProperty from mathutils import Vector @@ -28,7 +29,9 @@ def add_object(self, context): obj = bpy.data.objects.new("Grid", None) obj.name = "Grid" - bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcGrid") + bonsai.core.root.assign_class( + tool.Ifc, tool.Collector, tool.Root, obj=obj, ifc_class="IfcGrid", should_add_representation=False + ) grid = tool.Ifc.get_entity(obj) for i in range(0, self.total_u): @@ -69,6 +72,8 @@ def add_object(self, context): ifcopenshell.api.run("grid.create_axis_curve", tool.Ifc.get(), axis_curve=obj, grid_axis=result) tool.Collector.assign(obj) + tool.Root.reload_grid_decorator() + class BIM_OT_add_object(Operator, tool.Ifc.Operator): bl_idname = "mesh.add_grid" diff --git a/src/bonsai/bonsai/bim/module/model/handler.py b/src/bonsai/bonsai/bim/module/model/handler.py index 95a3190ab7..f513017eef 100644 --- a/src/bonsai/bonsai/bim/module/model/handler.py +++ b/src/bonsai/bonsai/bim/module/model/handler.py @@ -26,7 +26,6 @@ from bpy.app.handlers import persistent @persistent def load_post(*args): - ifcopenshell.api.add_pre_listener("attribute.edit_attributes", "Bonsai.Root.SyncName", root.sync_name) ifcopenshell.api.add_pre_listener("style.edit_presentation_style", "Bonsai.Root.SyncStyleName", root.sync_name) ifcopenshell.api.add_post_listener( @@ -43,9 +42,6 @@ def load_post(*args): product.regenerate_profile_usage, ) - ifcopenshell.api.add_post_listener( - "geometry.add_representation", "Bonsai.DumbWall.CalculateQuantities", wall.calculate_quantities - ) ifcopenshell.api.add_post_listener( "material.edit_layer", "Bonsai.DumbWall.RegenerateFromLayer", wall.DumbWallPlaner().regenerate_from_layer ) @@ -53,9 +49,6 @@ def load_post(*args): "type.assign_type", "Bonsai.DumbWall.RegenerateFromType", wall.DumbWallPlaner().regenerate_from_type ) - ifcopenshell.api.add_post_listener( - "geometry.add_representation", "Bonsai.DumbSlab.CalculateQuantities", slab.calculate_quantities - ) ifcopenshell.api.add_post_listener( "material.edit_layer", "Bonsai.DumbSlab.RegenerateFromLayer", slab.DumbSlabPlaner().regenerate_from_layer ) diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index f5ce337644..542924ddda 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -647,9 +647,7 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator): class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.mep_add_transition" bl_label = "Add Transition" - bl_description = ( - "Adds transition between two MEP elements. Elements are either provided by ID or selected in Blender" - ) + bl_description = "Adds transition between two selected MEP Elements" bl_options = {"REGISTER", "UNDO"} start_length: bpy.props.FloatProperty( name="Start Length", description="Transition start length in SI units", default=0.1, subtype="DISTANCE", min=0 @@ -892,7 +890,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.mep_add_bend" bl_label = "Add Bend" - bl_description = "Adds a bend between two MEP elements. Elements are either provided by ID or selected in Blender" + bl_description = "Adds a bend between two selected MEP Elements" bl_options = {"REGISTER", "UNDO"} start_length: bpy.props.FloatProperty( name="Start Length", description="Bend start length in SI units", default=0.1, subtype="DISTANCE", min=0 diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 55ccf90c31..72dc70b693 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -66,7 +66,6 @@ class FilledOpeningGenerator: target: Optional[Vector] = None, ) -> None: props = bpy.context.scene.BIMModelProperties - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) opening_thickness_si = 0.0 filling = tool.Ifc.get_entity(filling_obj) @@ -307,11 +306,23 @@ class FilledOpeningGenerator: get_curve_2d_from_3d(profile), magnitude=thickness / unit_scale, position=Vector([0.0, -thickness * 0.5 / unit_scale, 0.0]), - **shape_builder.extrude_kwargs("Y") + **shape_builder.extrude_kwargs("Y"), ) return shape_builder.get_representation(context, [extrusion]) - x, y, z = filling_obj.dimensions + if ( + filling_rep := tool.Geometry.get_active_representation(filling_obj) + ) and filling_rep.ContextOfItems == context: + x, y, z = filling_obj.dimensions + else: + # The filling_obj's mesh data is not the body geometry. + settings = ifcopenshell.geom.settings() + filling_element = tool.Ifc.get_entity(filling_obj) + filling_body = ifcopenshell.util.representation.get_representation(filling_element, context) + filling_geometry = ifcopenshell.geom.create_shape(settings, filling_body) + x = ifcopenshell.util.shape.get_x(filling_geometry) + y = ifcopenshell.util.shape.get_y(filling_geometry) + z = ifcopenshell.util.shape.get_z(filling_geometry) opening_position = Vector([0.0, -thickness * 0.5 / unit_scale, 0.0]) opening_size = Vector([x, z]) / unit_scale @@ -340,7 +351,7 @@ class FilledOpeningGenerator: shape_builder.rectangle(size=opening_size), magnitude=thickness / unit_scale, position=opening_position, - **shape_builder.extrude_kwargs("Y") + **shape_builder.extrude_kwargs("Y"), ) return shape_builder.get_representation(context, [extrusion]) @@ -432,7 +443,8 @@ class FlipFill(bpy.types.Operator, tool.Ifc.Operator): class AddPotentialOpening(Operator, AddObjectHelper): bl_idname = "bim.add_potential_opening" - bl_label = "Add Potential Opening" + bl_label = "Add Opening" + bl_description = "Add an Opening object which can be applied on an Element" bl_options = {"REGISTER", "UNDO"} x: FloatProperty(name="X", default=0.5) y: FloatProperty(name="Y", default=0.5) @@ -727,7 +739,8 @@ class RemoveBooleans(Operator, tool.Ifc.Operator, AddObjectHelper): class ShowOpenings(Operator, tool.Ifc.Operator): bl_idname = "bim.show_openings" - bl_label = "Show Openings" + bl_label = "Show" + bl_description = "Show Openings" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): @@ -754,7 +767,8 @@ class ShowOpenings(Operator, tool.Ifc.Operator): class HideOpenings(Operator, tool.Ifc.Operator): bl_idname = "bim.hide_openings" - bl_label = "Hide Openings" + bl_label = "Hide" + bl_description = "Hide Openings" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): @@ -780,7 +794,8 @@ class HideOpenings(Operator, tool.Ifc.Operator): class EditOpenings(Operator, tool.Ifc.Operator): bl_idname = "bim.edit_openings" - bl_label = "Edit Openings" + bl_label = "Edit" + bl_description = "Edit Openings" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): @@ -844,8 +859,8 @@ class EditOpenings(Operator, tool.Ifc.Operator): class CloneOpening(Operator, tool.Ifc.Operator): bl_idname = "bim.clone_opening" bl_label = "Clone Opening" + bl_description = "Clone the selected Opening and assign to the selected Element" bl_options = {"REGISTER", "UNDO"} - bl_description = "Create and assign to the selected element the selected opening and assign to it the same representation and object placement" def _execute(self, context): objects = bpy.context.selected_objects diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py new file mode 100644 index 0000000000..35f14fade2 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -0,0 +1,362 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 Bruno Perdigão +# +# 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 +import copy +import math +import bmesh +import ifcopenshell +import ifcopenshell.api +import ifcopenshell.util.unit +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation +import ifcopenshell.util.type +import mathutils.geometry +import bonsai.core.type +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 +from mathutils import Vector, Matrix +from bonsai.bim.module.model.opening import FilledOpeningGenerator +from bonsai.bim.module.model.decorator import PolylineDecorator +from typing import Optional +from lark import Lark, Transformer + + +class PolylineOperator: + # TODO Fill doc strings + """ """ + + @classmethod + def poll(cls, context): + return context.space_data.type == "VIEW_3D" + + def __init__(self): + self.mousemove_count = 0 + self.action_count = 0 + self.visible_objs = [] + self.objs_2d_bbox = [] + self.number_options = { + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + " ", + ".", + "+", + "-", + "*", + "/", + "'", + '"', + "=", + } + self.number_input = [] + self.number_output = "" + self.number_is_negative = False + self.input_options = ["D", "A", "X", "Y"] + self.input_type = None + self.input_type = None + self.input_value_xy = [None, None] + self.input_ui = tool.Polyline.create_input_ui() + self.is_typing = False + self.snap_angle = None + self.snapping_points = [] + self.instructions = """TAB: Cycle Input + D: Distance Input + A: Angle Input + M: Modify Snap Point + C: Close Polyline + BACKSPACE: Remove Point + X, Y: Choose Axis + SHIFT: Lock axis + """ + self.snap_info = """ + Snap: + Axis: + Plane: + """ + + self.tool_state = tool.Polyline.create_tool_state() + + def recalculate_inputs(self, context): + if self.number_input: + is_valid, self.number_output = tool.Polyline.validate_input(self.number_output, self.input_type) + self.input_ui.set_value(self.input_type, self.number_output) + if not is_valid: + self.report({"WARNING"}, "The number typed is not valid.") + return is_valid + else: + if self.input_type in {"X", "Y"}: + 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) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + else: + self.input_ui.set_value(self.input_type, self.number_output) + tool.Blender.update_viewport() + return is_valid + + def choose_axis(self, event, x=True, y=True, z=False): + if x: + if event.value == "PRESS" and event.type == "X": + self.tool_state.axis_method = "X" if self.tool_state.axis_method != event.type else None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if y: + if event.value == "PRESS" and event.type == "Y": + self.tool_state.axis_method = "Y" if self.tool_state.axis_method != event.type else None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + if z: + if event.value == "PRESS" and event.type == "Z": + self.tool_state.axis_method = "Z" if self.tool_state.axis_method != event.type else None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def choose_plane(self, event, x=True, y=True, z=True): + 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.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.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.axis_method = None + tool.Blender.update_viewport() + + def handle_instructions(self, context): + self.snap_info = f"""| + Axis: {self.tool_state.axis_method} + Plane: {self.tool_state.plane_method} + Snap: {self.snapping_points[0][1]} + """ + context.workspace.status_text_set(self.instructions + self.snap_info) + + def handle_keyboard_input(self, context, event): + + if self.tool_state.is_input_on and event.value == "PRESS" and event.type == "TAB": + self.recalculate_inputs(context) + index = self.input_options.index(self.input_type) + size = len(self.input_options) + self.input_type = self.input_options[((index + 1) % size)] + self.tool_state.input_type = self.input_options[((index + 1) % size)] + self.tool_state.mode = "Select" + self.is_typing = False + self.number_input = self.input_ui.get_formatted_value(self.input_type) + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + self.input_ui.set_value(self.input_type, self.number_output) + 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 and event.value == "RELEASE" and event.type == "TAB": + self.recalculate_inputs(context) + self.tool_state.mode = "Select" + self.tool_state.is_input_on = True + self.input_type = "D" + self.tool_state.input_type = "D" + self.is_typing = False + self.number_input = self.input_ui.get_formatted_value(self.input_type) + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + self.input_ui.set_value(self.input_type, self.number_output) + 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 and event.ascii in self.number_options: + self.recalculate_inputs(context) + self.tool_state.mode = "Edit" + self.tool_state.is_input_on = True + self.input_type = "D" + self.tool_state.input_type = "D" + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if event.value == "RELEASE" and event.type in {"D", "A"}: + self.recalculate_inputs(context) + self.tool_state.mode = "Edit" + self.tool_state.is_input_on = True + self.input_type = event.type + self.tool_state.input_type = event.type + self.input_ui.set_value(self.input_type, "") + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if self.input_type in self.input_options: + if (event.ascii in self.number_options) or (event.value == "RELEASE" and event.type == "BACK_SPACE"): + if not self.tool_state.mode == "Edit" and not (event.ascii == "=" or event.type == "BACK_SPACE"): + self.number_input = [] + + if event.type == "BACK_SPACE": + if len(self.number_input) <= 1: + self.number_input = [] + else: + self.number_input.pop(-1) + elif event.ascii == "=": + if self.number_input and self.number_input[0] == "=": + self.number_input.pop(0) + else: + self.number_input.insert(0, "=") + else: + self.number_input.append(event.ascii) + + if not self.number_input: + self.number_output = "0" + + self.tool_state.mode = "Edit" + self.is_typing = True + self.number_output = "".join(self.number_input) + self.input_ui.set_value(self.input_type, self.number_output) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def handle_inserting_polyline(self, context, event): + if event.value == "RELEASE" and event.type == "LEFTMOUSE": + result = tool.Snap.insert_polyline_point(self.input_ui) + if result: + self.report({"WARNING"}, result) + tool.Blender.update_viewport() + + if event.value == "PRESS" and event.type == "C": + tool.Snap.close_polyline() + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if ( + self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + is_valid = self.recalculate_inputs(context) + if is_valid: + result = tool.Snap.insert_polyline_point(self.input_ui) + if result: + self.report({"WARNING"}, result) + + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + self.number_input = [] + self.number_output = "" + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def handle_snap_selection(self, context, event): + if event.value == "PRESS" and event.type == "M": + self.snapping_points = tool.Snap.modify_snapping_point_selection(self.snapping_points) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def handle_cancelation(self, context, event): + if self.tool_state.is_input_on: + if event.value == "RELEASE" and event.type in {"ESC"}: + self.recalculate_inputs(context) + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + else: + if event.value == "RELEASE" and event.type in {"ESC"}: + self.tool_state.axis_method = None + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + tool.Snap.clear_polyline() + tool.Blender.update_viewport() + return {"CANCELLED"} + + def handle_mouse_move(self, context, event): + if not self.tool_state.is_input_on: + if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": + self.mousemove_count += 1 + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Snap.clear_snapping_ref() + tool.Blender.update_viewport() + else: + self.mousemove_count = 0 + + if self.mousemove_count == 2: + self.objs_2d_bbox = [] + for obj in self.visible_objs: + self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) + + if self.mousemove_count > 3: + 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) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + tool.Blender.update_viewport() + return {"RUNNING_MODAL"} + + if event.value == "RELEASE" and event.type == "BACK_SPACE": + tool.Snap.remove_last_polyline_point() + tool.Blender.update_viewport() + + def modal(self, context, event): + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + return {"PASS_THROUGH"} + + def invoke(self, context, event): + PolylineDecorator.install(context) + tool.Snap.clear_snapping_point() + + self.tool_state.use_default_container = False + self.tool_state.axis_method = None + self.tool_state.plane_method = None + self.tool_state.mode = "Mouse" + tool.Snap.set_tool_state(self.tool_state) + self.visible_objs = tool.Raycast.get_visible_objects(context) + for obj in self.visible_objs: + self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) + 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) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + + tool.Blender.update_viewport() + context.window_manager.modal_handler_add(self) diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index 49f0bde998..6dec62794c 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -17,9 +17,9 @@ # along with Bonsai. If not, see . import bpy +import bmesh import mathutils import numpy as np -from functools import reduce import ifcopenshell import ifcopenshell.api import ifcopenshell.util.system @@ -31,10 +31,12 @@ import ifcopenshell.util.unit import bonsai.tool as tool import bonsai.core.aggregate import bonsai.core.type +import bonsai.core.root import bonsai.core.geometry import bonsai.core.spatial from . import wall, slab, profile, mep from bonsai.bim.ifc import IfcStore +from bonsai.bim.helper import get_enum_items from bonsai.bim.module.model.data import AuthoringData from mathutils import Vector, Matrix from bpy_extras.object_utils import AddObjectHelper @@ -73,13 +75,10 @@ class AddEmptyType(bpy.types.Operator, AddObjectHelper): return {"FINISHED"} -def add_empty_type_button(self, context): - self.layout.operator(AddEmptyType.bl_idname, icon="FILE_3D") - - class AddDefaultType(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_default_type" - bl_label = "Add Default Type" + bl_label = "Create" + bl_description = "Create Construction Type" bl_options = {"REGISTER", "UNDO"} ifc_element_type: bpy.props.StringProperty() @@ -117,19 +116,49 @@ class AddDefaultType(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.add_type() -class AddConstrTypeInstance(bpy.types.Operator): +class AddConstrTypeInstance(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_constr_type_instance" - bl_label = "Add" + bl_label = "Add Type Occurrence" bl_options = {"REGISTER", "UNDO"} - bl_description = "Add Type Instance to the model" - relating_type_id: bpy.props.IntProperty() - from_invoke: bpy.props.BoolProperty(default=False) + bl_description = "Add Type Instance" + relating_type_id: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) + from_invoke: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + representation_template: bpy.props.EnumProperty( + name="Representation Template", + items=( + ("EMPTY", "No Geometry", "Start with an empty object"), + None, + ( + "MESH", + "Custom Tessellation", + "Create a basic tessellated or faceted cube", + ), + ( + "EXTRUSION", + "Custom Extruded Solid", + "An extrusion from an arbitrary profile", + ), + ), + ) def invoke(self, context, event): + props = context.scene.BIMModelProperties + relating_type_id = self.relating_type_id or props.relating_type_id + if ( + relating_type_id + and (relating_type := tool.Ifc.get().by_id(int(relating_type_id))) + and not relating_type.RepresentationMaps + ): + if (material := ifcopenshell.util.element.get_material(relating_type)) and ( + material.is_a("IfcMaterialProfileSet") or material.is_a("IfcMaterialLayerSet") + ): + return self.execute(context) + return context.window_manager.invoke_props_dialog(self) return self.execute(context) - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) + def draw(self, context): + row = self.layout.row() + row.prop(self, "representation_template", text="") def _execute(self, context): props = context.scene.BIMModelProperties @@ -166,49 +195,100 @@ class AddConstrTypeInstance(bpy.types.Operator): if self.generate_layered_element(ifc_class, relating_type): tool.Blender.select_and_activate_single_object(context, context.selected_objects[-1]) return {"FINISHED"} + elif not relating_type.RepresentationMaps: + mesh = None if self.representation_template == "EMPTY" else bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(relating_type, instance_class), mesh) + obj.location = bpy.context.scene.cursor.location + element = bonsai.core.root.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class=instance_class, + should_add_representation=False, + ) + bonsai.core.type.assign_type(tool.Ifc, tool.Type, element=element, type=relating_type) + + rprops = context.scene.BIMRootProperties + ifc_context = None + if get_enum_items(rprops, "contexts", context): + ifc_context = int(rprops.contexts or "0") or None + if ifc_context: + ifc_context = tool.Ifc.get().by_id(ifc_context) + + if self.representation_template == "EMPTY" or not ifc_context: + pass + elif self.representation_template == "MESH": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=0.5) + verts = [v.co / 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) + bm.free() + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + elif self.representation_template == "EXTRUSION": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale) + item = builder.extrude(curve, magnitude=0.5 / unit_scale) + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + return building_obj = None if len(context.selected_objects) == 1 and context.active_object: building_obj = context.active_object building_element = tool.Ifc.get_entity(building_obj) - # A cube - verts = [ - Vector((-1, -1, -1)), - Vector((-1, -1, 1)), - Vector((-1, 1, -1)), - Vector((-1, 1, 1)), - Vector((1, -1, -1)), - Vector((1, -1, 1)), - Vector((1, 1, -1)), - Vector((1, 1, 1)), - ] - edges = [] - faces = [ - [0, 1, 3, 2], - [2, 3, 7, 6], - [6, 7, 5, 4], - [4, 5, 1, 0], - [2, 6, 4, 0], - [7, 3, 1, 5], - ] mesh = bpy.data.meshes.new(name="Instance") - mesh.from_pydata(verts, edges, faces) obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(relating_type, instance_class), mesh) obj.location = context.scene.cursor.location - bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class) - tool.Blender.remove_data_block(mesh) # Remove "Instance" mesh + element = bonsai.core.root.assign_class( + tool.Ifc, tool.Collector, tool.Root, obj=obj, ifc_class=instance_class, should_add_representation=False + ) - mesh_data = obj.data element = tool.Ifc.get_entity(obj) bonsai.core.type.assign_type(tool.Ifc, tool.Type, element=element, type=relating_type) - if obj.data != mesh_data: # remove orphaned mesh from "bim.assign_class" - tool.Blender.remove_data_block(mesh_data) + + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation and element.Representation: + representation = element.Representation.Representations[0] + + if representation: + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) # Update required as core.type.assign_type may change obj.data - # TODO: This is inefficient. It literally creates a mesh, then potentially removes it. context.view_layer.update() if ( @@ -319,6 +399,18 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class SetActiveType(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.set_active_type" + bl_label = "Set Active Type" + bl_options = {"REGISTER"} + relating_type: bpy.props.IntProperty() + + def _execute(self, context): + props = context.scene.BIMModelProperties + props.relating_type_id = str(self.relating_type) + context.window.screen = context.window.screen # Closes the type manager popup + + class AlignProduct(bpy.types.Operator): bl_idname = "bim.align_product" bl_label = "Align Product" diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index 480db36490..e10eb578bf 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -138,7 +138,6 @@ class DumbProfileGenerator: is_global=True, should_sync_changes_first=False, ) - tool.Blender.remove_data_block(mesh) pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="EPset_Parametric") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Engine": "Bonsai.DumbProfile"}) @@ -844,7 +843,8 @@ class DumbProfileRecalculator: class ChangeProfileDepth(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_profile_depth" - bl_label = "Change Profile Length" + bl_label = "Update" + bl_description = "Update Profile Length" bl_options = {"REGISTER", "UNDO"} depth: bpy.props.FloatProperty(subtype="DISTANCE") @@ -861,7 +861,8 @@ class ChangeProfileDepth(bpy.types.Operator, tool.Ifc.Operator): class ChangeCardinalPoint(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_cardinal_point" - bl_label = "Change Cardinal Point" + bl_label = "Update" + bl_description = "Update Cardinal Point" bl_options = {"REGISTER", "UNDO"} cardinal_point: bpy.props.IntProperty() diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index b02df5ec43..fa7817042e 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -56,6 +56,12 @@ def get_type_predefined_type(self, context): return AuthoringData.data["type_predefined_type"] +def get_type_template(self, context): + if not AuthoringData.is_loaded: + AuthoringData.load() + return AuthoringData.data["type_template"] + + def update_ifc_class(self, context): bpy.ops.bim.load_type_thumbnails(ifc_class=self.ifc_class) AuthoringData.data["relating_type_id"] = AuthoringData.relating_type_id() @@ -71,6 +77,7 @@ def update_type_class(self, context): AuthoringData.data["next_page"] = AuthoringData.next_page() AuthoringData.data["paginated_relating_types"] = AuthoringData.paginated_relating_types() AuthoringData.data["type_predefined_type"] = AuthoringData.type_predefined_type() + AuthoringData.data["type_template"] = AuthoringData.type_template() type_class = self.type_class if (type_class, type_class, "") in get_ifc_class(self, context): @@ -79,6 +86,7 @@ def update_type_class(self, context): def update_relating_type_id(self, context): 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() @@ -99,23 +107,6 @@ def is_object_array_applicable(self, obj): return ifcopenshell.util.element.get_pset(element, "BBIM_Array") -class SnapMousePoint(PropertyGroup): - x: bpy.props.FloatProperty(name="X") - y: bpy.props.FloatProperty(name="Y") - z: bpy.props.FloatProperty(name="Z") - snap_type: bpy.props.StringProperty(name="Snap Type") - - -class PolylinePoint(PropertyGroup): - x: bpy.props.FloatProperty(name="X") - y: bpy.props.FloatProperty(name="Y") - z: bpy.props.FloatProperty(name="Z") - -class PolylineMeasurement(PropertyGroup): - dim: bpy.props.StringProperty(name="Dimension") - angle: bpy.props.StringProperty(name="Angle") - position: bpy.props.FloatVectorProperty(name="Decorator Position", size=3) - 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( @@ -133,7 +124,7 @@ class BIMModelProperties(PropertyGroup): description="Code that will be evaluated to generate occurrence name for CUSTOM occurrence name style", ) getter_enum = {"ifc_class": get_ifc_class, "relating_type": get_relating_type_id} - extrusion_depth: bpy.props.FloatProperty(min=0.001, default=42.0, subtype="DISTANCE") + extrusion_depth: bpy.props.FloatProperty(name="Extrusion Depth", min=0.001, default=42.0, subtype="DISTANCE") cardinal_point: bpy.props.EnumProperty( items=( # TODO: complain to buildingSMART @@ -160,7 +151,7 @@ class BIMModelProperties(PropertyGroup): name="Cardinal Point", default="5", ) - length: bpy.props.FloatProperty(default=42.0, subtype="DISTANCE") + length: bpy.props.FloatProperty(name="Length", default=42.0, subtype="DISTANCE") openings: bpy.props.CollectionProperty(type=ObjProperty) x: bpy.props.FloatProperty(name="X", default=0.5, subtype="DISTANCE", description="Size by X axis for the opening") y: bpy.props.FloatProperty(name="Y", default=0.5, subtype="DISTANCE", description="Size by Y axis for the opening") @@ -182,35 +173,11 @@ class BIMModelProperties(PropertyGroup): rl3: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for space calculation") x_angle: bpy.props.FloatProperty(name="X Angle", default=0, subtype="ANGLE", min=-pi / 180 * 89, max=pi / 180 * 89) type_page: bpy.props.IntProperty(name="Type Page", default=1, update=update_type_page) - # fmt: off - type_template: bpy.props.EnumProperty( - items=( - ("MESH", "Custom Mesh", "Use as a representation currently active object mesh or default cube if no object selected"), - ("LAYERSET_AXIS2", "Vertical Layers", "For objects similar to walls, will automatically add IfcMaterialLayerSet"), - ("LAYERSET_AXIS3", "Horizontal Layers", "For objects similar to slabs, will automatically add IfcMaterialLayerSet"), - ("PROFILESET", "Extruded Profile", "Create profile type object, automatically defines IfcMaterialProfileSet with the first profile from library"), - ("EMPTY", "Non-Geometric Type", "Start with an empty object"), - ("WINDOW", "Window", "Parametric window"), - ("DOOR", "Door", "Parametric door"), - ("STAIR", "Stair", "Parametric stair"), - ("RAILING", "Railing", "Parametric railing"), - ("ROOF", "Roof", "Parametric roof"), - ("FLOW_SEGMENT_RECTANGULAR", "Rectangular Distribution Segment", "Works similarly to Profile, has distribution ports"), - ("FLOW_SEGMENT_CIRCULAR", "Circular Distribution Segment", "Works similarly to Profile, has distribution ports"), - ("FLOW_SEGMENT_CIRCULAR_HOLLOW", "Circular Hollow Distribution Segment", "Works similarly to Profile, has distribution ports"), - ), - name="Type Template", - default="MESH", - ) - # fmt: on + type_template: bpy.props.EnumProperty(items=get_type_template, name="Type Template", default=0, options=set()) type_class: bpy.props.EnumProperty(items=get_type_class, name="IFC Class", update=update_type_class) type_predefined_type: bpy.props.EnumProperty(items=get_type_predefined_type, name="Predefined Type", default=None) type_name: bpy.props.StringProperty(name="Name", default="TYPEX") boundary_class: bpy.props.EnumProperty(items=get_boundary_class, name="Boundary Class") - snap_mouse_point: bpy.props.CollectionProperty(type=SnapMousePoint) - snap_mouse_ref: bpy.props.CollectionProperty(type=SnapMousePoint) - polyline_point: bpy.props.CollectionProperty(type=PolylinePoint) - polyline_measurement: bpy.props.CollectionProperty(type=PolylineMeasurement) class BIMArrayProperties(PropertyGroup): @@ -794,3 +761,30 @@ class BIMRoofProperties(PropertyGroup): def update_percentage(self): self.percentage = math.tan(self.angle) * 100 + + +class SnapMousePoint(PropertyGroup): + x: bpy.props.FloatProperty(name="X") + y: bpy.props.FloatProperty(name="Y") + z: bpy.props.FloatProperty(name="Z") + snap_type: bpy.props.StringProperty(name="Snap Type") + + +class PolylinePoint(PropertyGroup): + x: bpy.props.FloatProperty(name="X") + y: bpy.props.FloatProperty(name="Y") + z: bpy.props.FloatProperty(name="Z") + + +class PolylineMeasurement(PropertyGroup): + dim: bpy.props.StringProperty(name="Dimension") + angle: bpy.props.StringProperty(name="Angle") + position: bpy.props.FloatVectorProperty(name="Decorator Position", size=3) + + +class BIMPolylineProperties(PropertyGroup): + snap_mouse_point: bpy.props.CollectionProperty(type=SnapMousePoint) + snap_mouse_ref: bpy.props.CollectionProperty(type=SnapMousePoint) + polyline_point: bpy.props.CollectionProperty(type=PolylinePoint) + polyline_measurement: bpy.props.CollectionProperty(type=PolylineMeasurement) + product_preview: bpy.props.CollectionProperty(type=PolylinePoint) diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index d3f4262e71..5c9246eaa0 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -325,6 +325,7 @@ class BIM_OT_add_railing(bpy.types.Operator, tool.Ifc.Operator): class AddRailing(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_railing" bl_label = "Add Railing" + bl_description = "Add Bonsai parametric railing to the active IFC element" bl_options = {"REGISTER"} def _execute(self, context): @@ -445,11 +446,13 @@ class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_railing_path" - bl_label = "Enable Editing Railing Path" + bl_label = "Edit Railing" + bl_description = "Enable Editing Railing Path" bl_options = {"REGISTER"} def _execute(self, context): obj = context.active_object + [o.select_set(False) for o in context.selected_objects if o != obj] props = obj.BIMRailingProperties 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 diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py index 05263c82d7..f50ed5879c 100644 --- a/src/bonsai/bonsai/bim/module/model/roof.py +++ b/src/bonsai/bonsai/bim/module/model/roof.py @@ -541,6 +541,7 @@ class BIM_OT_add_roof(bpy.types.Operator, tool.Ifc.Operator): class AddRoof(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_roof" bl_label = "Add Roof" + bl_description = "Add Bonsai parametric roof to the active IFC element" bl_options = {"REGISTER"} def _execute(self, context): @@ -639,11 +640,13 @@ class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_roof_path" - bl_label = "Enable Editing Roof Path" + bl_label = "Edit Roof" + bl_description = "Enable Editing Roof Path" bl_options = {"REGISTER"} def _execute(self, context): obj = context.active_object + [o.select_set(False) for o in context.selected_objects if o != obj] props = obj.BIMRoofProperties 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 diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 63f596f1d3..56c4c7aa65 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -36,82 +36,6 @@ from bonsai.bim.module.model.decorator import ProfileDecorator from typing import Optional -def calculate_quantities(usecase_path, ifc_file, settings): - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - obj = settings["blender_object"] - product = ifc_file.by_id(obj.BIMObjectProperties.ifc_definition_id) - parametric = ifcopenshell.util.element.get_psets(product).get("EPset_Parametric") - if not parametric or "Engine" not in parametric or parametric["Engine"] != "Bonsai.DumbLayer3": - return - qto = ifcopenshell.api.run( - "pset.add_qto", ifc_file, should_run_listeners=False, product=product, name="Qto_SlabBaseQuantities" - ) - length = obj.dimensions[0] / unit_scale - width = obj.dimensions[1] / unit_scale - depth = obj.dimensions[2] / unit_scale - - perimeter = 0 - helper = Helper(ifc_file) - indices = helper.auto_detect_arbitrary_profile_with_voids_extruded_area_solid(settings["geometry"]) - - bm = bmesh.new() - bm.from_mesh(settings["geometry"]) - bm.verts.ensure_lookup_table() - - def calculate_profile_length(indices): - indices.append(indices[0]) # Close the loop - edge_vert_pairs = list(zip(indices, indices[1:])) - return sum([(bm.verts[p[1]].co - bm.verts[p[0]].co).length for p in edge_vert_pairs]) - - perimeter += calculate_profile_length(indices["profile"]) - for inner_indices in indices["inner_curves"]: - perimeter += calculate_profile_length(inner_indices) - - bm.free() - - if product.HasOpenings: - # TODO: calculate gross / net - gross_area = 0 - net_area = 0 - gross_volume = 0 - net_volume = 0 - else: - bm = bmesh.new() - bm.from_object(obj, bpy.context.evaluated_depsgraph_get()) - bm.faces.ensure_lookup_table() - gross_area = sum([f.calc_area() for f in bm.faces if f.normal.z > 0.9]) - net_area = gross_area - gross_volume = bm.calc_volume() - net_volume = gross_volume - bm.free() - - properties = { - "Depth": round(depth, 2), - "Perimeter": round(perimeter, 2), - "GrossArea": round(gross_area, 2), - "NetArea": round(net_area, 2), - "GrossVolume": round(gross_volume, 2), - "NetVolume": round(net_volume, 2), - } - - if round(obj.dimensions[0] * obj.dimensions[1] * obj.dimensions[2], 2) == round(gross_volume, 2): - properties.update( - { - "Length": round(length, 2), - "Width": round(width, 2), - } - ) - else: - properties.update( - { - "Length": None, - "Width": None, - } - ) - - ifcopenshell.api.run("pset.edit_qto", ifc_file, should_run_listeners=False, qto=qto, properties=properties) - - class DumbSlabGenerator: def __init__(self, relating_type: ifcopenshell.entity_instance): self.relating_type = relating_type @@ -202,7 +126,6 @@ class DumbSlabGenerator: is_global=True, should_sync_changes_first=False, ) - tool.Blender.remove_data_block(mesh) if self.footprint_context: extrusion = tool.Model.get_extrusion(representation) @@ -596,7 +519,6 @@ def disable_editing_extrusion_profile(context): is_global=True, should_sync_changes_first=False, ) - tool.Geometry.delete_data(profile_mesh) return {"FINISHED"} @@ -697,7 +619,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): is_global=True, should_sync_changes_first=False, ) - bpy.data.meshes.remove(profile_mesh) # Only certain classes should have a footprint if element.is_a() not in ("IfcSlab", "IfcRamp"): diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 70a56be5a8..7972daa926 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -73,7 +73,7 @@ def update_stair_representation(obj): context=body, blender_object=obj, geometry=obj.data, - coordinate_offset=tool.Geometry.get_cartesian_point_coordinate_offset(obj), + 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(), should_force_triangulation=tool.Geometry.should_force_triangulation(), @@ -93,7 +93,7 @@ def update_ifc_stair_props(obj): props = obj.BIMStairProperties ifc_file = tool.Ifc.get() - if tool.Ifc.get_schema() != "IFC2X3": + if tool.Ifc.get_schema() != "IFC2X3" and element.is_a("IfcStairFlight"): element.PredefinedType = "STRAIGHT" number_of_risers = props.number_of_treads + 1 # update IfcStairFlight properties (seems already deprecated but keep it for now) @@ -198,6 +198,7 @@ class BIM_OT_add_stair(bpy.types.Operator, tool.Ifc.Operator): class AddStair(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_stair" bl_label = "Add Stair" + bl_description = "Add Bonsai parametric stair to the active IFC element" bl_options = {"REGISTER"} def _execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 3e18849af7..fbc443f94e 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -40,6 +40,15 @@ from bonsai.bim.helper import prop_with_search from collections.abc import Iterable +class BIM_MT_type_manager_menu(bpy.types.Menu): + bl_label = "Type Manager Menu" + bl_idname = "BIM_MT_type_manager_menu" + + def draw(self, context): + layout = self.layout + layout.operator("bim.purge_unused_objects", text="Purge Unused Types", icon="TRASH").object_type = "TYPE" + + class LaunchTypeManager(bpy.types.Operator): bl_idname = "bim.launch_type_manager" bl_label = "Launch Type Manager" @@ -61,14 +70,17 @@ class LaunchTypeManager(bpy.types.Operator): if ifc_class is not None: props.type_class = ifc_class bpy.ops.bim.load_type_thumbnails(ifc_class=ifc_class, offset=0, limit=9) - return context.window_manager.invoke_popup(self, width=550) + return context.window_manager.invoke_props_dialog( + self, width=550, title="Type Manager", confirm_text="Add Type" + ) def draw(self, context): props = context.scene.BIMModelProperties row = self.layout.row(align=True) - prop_with_search(row, props, "type_class", text="") - row.operator("bim.purge_unused_types", icon="TRASH", text="") + prop_with_search(row, props, "type_class", text="", should_click_ok_to_validate=True) + + row.menu("BIM_MT_type_manager_menu", text="", icon="PREFERENCES") columns = self.layout.column_flow(columns=3) row = columns.row() @@ -104,7 +116,8 @@ class LaunchTypeManager(bpy.types.Operator): row.operator("bim.disable_add_type", icon="CANCEL", text="") else: row = self.layout.row() - row.operator("bim.enable_add_type", icon="ADD", text="Create New Type") + # row.operator("bim.enable_add_type", icon="ADD", text="Create New Type") + row.operator("bim.launch_add_element", icon="ADD", text="Create New Type") flow = self.layout.grid_flow(row_major=True, columns=3, even_columns=True, even_rows=True, align=True) @@ -114,16 +127,26 @@ class LaunchTypeManager(bpy.types.Operator): row = box.row() row.alignment = "CENTER" - row.label(text=relating_type["name"], icon="FILE_3D") + op = row.operator("bim.set_active_type", text=relating_type["name"], icon="FILE_3D", emboss=False) + op.relating_type = relating_type["id"] row = box.row() row.alignment = "CENTER" - row.label(text=relating_type["description"]) + op = row.operator("bim.set_active_type", text=relating_type["description"], emboss=False) + op.relating_type = relating_type["id"] - row = box.row() if relating_type["icon_id"]: - row.template_icon(icon_value=relating_type["icon_id"], scale=4) + # 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) + 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"] + 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="Load Thumbnails", icon="FILE_REFRESH") op.ifc_class = props.type_class @@ -160,19 +183,6 @@ class BIM_PT_authoring(Panel): row.operator("bim.toggle_space_visibility") -class BIM_PT_Grids(Panel): - bl_label = "Grids" - bl_idname = "BIM_PT_Grids" - bl_options = {"DEFAULT_CLOSED"} - bl_space_type = "PROPERTIES" - bl_region_type = "WINDOW" - bl_context = "scene" - bl_parent_id = "BIM_PT_tab_project_setup" - - def draw(self, context): - self.layout.row().operator("mesh.add_grid", icon="ADD", text="Add Grids") - - class BIM_PT_array(bpy.types.Panel): bl_label = "Array" bl_idname = "BIM_PT_array" @@ -455,7 +465,7 @@ class BIM_PT_window(bpy.types.Panel): else: row = self.layout.row() row.label(text="No Window Found") - row.operator("bim.add_window", icon="ADD", text="").obj = "" + row.operator("bim.add_window", icon="ADD", text="") class BIM_PT_door(bpy.types.Panel): @@ -647,27 +657,16 @@ class BIM_PT_roof(bpy.types.Panel): row.operator("bim.add_roof", icon="ADD", text="") -class BIM_MT_model(Menu): - bl_idname = "BIM_MT_model" - bl_label = "Objects" +class BIM_MT_elements(Menu): + bl_idname = "BIM_MT_elements" + bl_label = "IFC Elements" def draw(self, context): - layout = self.layout - layout.operator("bim.add_empty_type", text="Empty Type", icon="EMPTY_AXIS") - layout.operator("bim.add_potential_half_space_solid", text="Half Space Proxy", icon="ORIENTATION_NORMAL") - layout.operator("bim.add_potential_opening", text="Opening Proxy", icon="CUBE") + # TODO consolidate in Item mode UI then remove + self.layout.operator("bim.add_potential_half_space_solid", text="Half Space", icon="ORIENTATION_NORMAL") + self.layout.operator("bim.add_potential_opening", text="Opening", icon="CUBE") def add_menu(self, context): - self.layout.menu("BIM_MT_model", icon="FILE_3D") - - -def add_mesh_object_menu(self, context): - if context.mode == "OBJECT": - self.layout.separator() - self.layout.operator("mesh.add_grid", icon_value=bonsai.bim.icons["IFC"].icon_id) - self.layout.operator("mesh.add_stair", icon_value=bonsai.bim.icons["IFC"].icon_id, text="Stair (Untyped)") - self.layout.operator("mesh.add_window", icon_value=bonsai.bim.icons["IFC"].icon_id, text="Window (Untyped)") - self.layout.operator("mesh.add_door", icon_value=bonsai.bim.icons["IFC"].icon_id, text="Door (Untyped)") - self.layout.operator("mesh.add_railing", icon_value=bonsai.bim.icons["IFC"].icon_id, text="Railing (Untyped)") - self.layout.operator("mesh.add_roof", icon_value=bonsai.bim.icons["IFC"].icon_id, text="Roof (Untyped)") + self.layout.operator("bim.launch_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 d34fd01262..c6e6145a56 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -38,6 +38,7 @@ 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 +from bonsai.bim.module.model.polyline import PolylineOperator from typing import Optional from lark import Lark, Transformer @@ -45,6 +46,7 @@ from lark import Lark, Transformer class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unjoin_walls" bl_label = "Unjoin Walls" + bl_description = "Unjoin the selected walls" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -156,7 +158,8 @@ class RecalculateWall(bpy.types.Operator, tool.Ifc.Operator): class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_extrusion_depth" - bl_label = "Change Extrusion Depth" + bl_label = "Update" + bl_description = "Update Height" bl_options = {"REGISTER", "UNDO"} depth: bpy.props.FloatProperty() @@ -197,7 +200,8 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_extrusion_x_angle" - bl_label = "Change Extrusion X Angle" + bl_label = "Update" + bl_description = "Update Angle" bl_options = {"REGISTER", "UNDO"} x_angle: bpy.props.FloatProperty(name="X Angle", default=0, subtype="ANGLE") @@ -250,7 +254,8 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_layer_length" - bl_label = "Change Layer Length" + bl_label = "Update" + bl_description = "Update Length" bl_options = {"REGISTER", "UNDO"} length: bpy.props.FloatProperty() @@ -280,7 +285,7 @@ def recalculate_dumb_wall_origin(wall, new_origin=None): child.matrix_parent_inverse = wall.matrix_world.inverted() -class DrawPolylineWall(bpy.types.Operator): +class DrawPolylineWall(bpy.types.Operator, PolylineOperator): bl_idname = "bim.draw_polyline_wall" bl_label = "Draw Polyline Wall" bl_options = {"REGISTER", "UNDO"} @@ -290,240 +295,79 @@ class DrawPolylineWall(bpy.types.Operator): return context.space_data.type == "VIEW_3D" def __init__(self): - self.mousemove_count = 0 - self.action_count = 0 - self.visible_objs = [] - self.objs_2d_bbox = [] - self.number_options = { - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - " ", - ".", - "+", - "-", - "*", - "/", - "'", - '"', - "=", - } - self.number_input = [] - self.number_output = "" - self.number_is_negative = False - self.is_input_on = False - self.input_options = ["D", "A", "X", "Y"] - self.input_type = "OFF" - self.input_value_xy = [None, None] - self.input_panel = {"D": "", "A": "", "X": "", "Y": ""} - self.snap_angle = None - self.snapping_points = [] + super().__init__() + self.relating_type = None + relating_type_id = bpy.context.scene.BIMModelProperties.relating_type_id + if relating_type_id: + self.relating_type = tool.Ifc.get().by_id(int(relating_type_id)) - def recalculate_inputs(self, context): - if self.number_input: - is_valid, self.number_output = tool.Snap.validate_input(self.number_output, self.input_type) - self.input_panel[self.input_type] = self.number_output - if not is_valid: - self.report({"WARNING"}, "The number typed is not valid.") - return is_valid - else: - if self.input_type in {"X", "Y"}: - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - elif self.input_type in {"D", "A"}: - self.input_panel = PolylineDecorator.calculate_x_y_and_z(context) - - self.input_panel[self.input_type] = self.number_output - - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - return is_valid - - # TODO This is creating a hack in generate function from DumbWallGenerator - # Come up with a better solution def create_walls_from_polyline(self, context): - props = context.scene.BIMModelProperties - relating_type_id = props.relating_type_id - - if not relating_type_id: + if not self.relating_type: return {"FINISHED"} - self.container = None - self.container_obj = None - if container := tool.Root.get_default_container(): - self.container = container - self.container_obj = tool.Ifc.get_object(container) - - relating_type = tool.Ifc.get().by_id(int(relating_type_id)) - - walls, is_polyline_closed = DumbWallGenerator(relating_type).generate(True) + walls, is_polyline_closed = DumbWallGenerator(self.relating_type).generate(True) if walls: if is_polyline_closed: for wall1, wall2 in zip(walls, walls[1:] + [walls[0]]): - DumbWallJoiner().join_V(wall1["obj"], wall2["obj"]) + DumbWallJoiner().join_V(wall2["obj"], wall1["obj"]) else: for wall1, wall2 in zip(walls[:-1], walls[1:]): - DumbWallJoiner().join_V(wall1["obj"], wall2["obj"]) + DumbWallJoiner().join_V(wall2["obj"], wall1["obj"]) def modal(self, context, event): - - if not self.is_input_on: - if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": - self.mousemove_count += 1 - self.is_input_on = False - self.input_type = "OFF" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Snap.clear_snapping_ref() - tool.Blender.update_viewport() - else: - self.mousemove_count = 0 - - if self.mousemove_count == 2: - self.objs_2d_bbox = [] - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_objects_2d_bounding_boxes(context, obj)) - - if self.mousemove_count > 3: - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - return {"RUNNING_MODAL"} - - if event.value == "RELEASE" and event.type == "BACK_SPACE": - tool.Snap.remove_last_polyline_point() - tool.Blender.update_viewport() - - if event.value == "RELEASE" and event.type == "LEFTMOUSE": - tool.Snap.insert_polyline_point(self.input_panel) - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "C": - tool.Snap.close_polyline() - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.is_input_on and event.value == "PRESS" and event.type == "TAB": - self.recalculate_inputs(context) - index = self.input_options.index(self.input_type) - size = len(self.input_options) - self.input_type = self.input_options[((index + 1) % size)] - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.value == "RELEASE" and event.type == "TAB": - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.ascii in self.number_options: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if event.value == "RELEASE" and event.type in self.input_options: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = event.type - self.number_input = [] - self.input_panel[self.input_type] = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.input_type in self.input_options: - if (event.ascii in self.number_options) or (event.value == "RELEASE" and event.type == "BACK_SPACE"): - if event.type == "BACK_SPACE": - if len(self.number_input) <= 1: - self.number_input = [] - else: - self.number_input = self.number_input[:-1] - self.number_output = "".join(self.number_input) - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - self.number_input.append(event.ascii) - self.number_output = "".join(self.number_input) - - if self.number_input: - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: - self.create_walls_from_polyline(context) + if not self.relating_type: + self.report({"WARNING"}, "You need to select a wall type.") PolylineDecorator.uninstall() + tool.Blender.update_viewport() + return {"FINISHED"} + + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + self.handle_instructions(context) + + self.handle_mouse_move(context, event) + + self.choose_axis(event) + + self.handle_snap_selection(context, event) + + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + self.create_walls_from_polyline(context) + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + context.scene.BIMPolylineProperties.product_preview.clear() tool.Snap.clear_polyline() tool.Blender.update_viewport() return {"FINISHED"} - if self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: - is_valid = self.recalculate_inputs(context) - if is_valid: - tool.Snap.insert_polyline_point(self.input_panel) - self.is_input_on = False - self.input_type = "OFF" - self.number_input = [] - self.number_output = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() + self.handle_keyboard_input(context, event) - if event.value == "PRESS" and event.type == "M": - self.snapping_points = tool.Snap.modify_snapping_point_selection(self.snapping_points) - tool.Blender.update_viewport() + self.handle_inserting_polyline(context, event) - if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: - return {"PASS_THROUGH"} + if event.type in {"C", "LEFTMOUSE", "RIGHTMOUSE", "RET", "NUMPAD_ENTER", "BACK_SPACE"}: + tool.Polyline.create_wall_preview_vertices(context, self.relating_type) - if self.is_input_on: - if event.value == "RELEASE" and event.type in {"ESC"}: - self.is_input_on = False - self.input_type = "OFF" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - if event.value == "RELEASE" and event.type in {"ESC"}: - PolylineDecorator.uninstall() - tool.Snap.clear_polyline() - tool.Blender.update_viewport() - return {"CANCELLED"} + cancel = self.handle_cancelation(context, event) + if cancel is not None: + context.scene.BIMPolylineProperties.product_preview.clear() + return cancel return {"RUNNING_MODAL"} def invoke(self, context, event): - if context.space_data.type == "VIEW_3D": - PolylineDecorator.install(context) - tool.Snap.set_use_default_container(True) - PolylineDecorator.set_use_default_container(True) - tool.Snap.set_snap_plane_method("XY") - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - self.visible_objs = tool.Raycast.get_visible_objects(context) - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_objects_2d_bounding_boxes(context, obj)) - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - context.window_manager.modal_handler_add(self) - return {"RUNNING_MODAL"} - else: - self.report({"WARNING"}, "Active space must be a View3d") - return {"CANCELLED"} + super().invoke(context, event) + self.tool_state.use_default_container = True + self.tool_state.plane_method = "XY" + return {"RUNNING_MODAL"} class DumbWallAligner: @@ -676,7 +520,7 @@ class DumbWallGenerator: ) def derive_from_polyline(self): - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point is_polyline_closed = False if len(polyline_data) > 3: first_vec = Vector((polyline_data[0].x, polyline_data[0].y, polyline_data[0].z)) @@ -725,16 +569,16 @@ class DumbWallGenerator: bpy.context.scene.grease_pencil.layers.remove(layer) return objs - def create_wall_from_2_points(self, coords, round=False): + def create_wall_from_2_points(self, coords, should_round=False): direction = coords[1] - coords[0] length = direction.length - if length < 0.1: + if round(length, 4) < 0.1: return data = {"coords": coords} self.length = length self.rotation = math.atan2(direction[1], direction[0]) - if round: + if should_round: # Round to nearest 50mm (yes, metric for now) self.length = 0.05 * round(length / 0.05) # Round to nearest 5 degrees @@ -868,7 +712,6 @@ class DumbWallGenerator: is_global=True, should_sync_changes_first=False, ) - tool.Blender.remove_data_block(mesh) pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="EPset_Parametric") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"}) obj.select_set(True) @@ -879,58 +722,6 @@ class DumbWallGenerator: return [c for c in classes if "StandardCase" not in c][0] -def calculate_quantities(usecase_path, ifc_file, settings): - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - obj = settings["blender_object"] - product = ifc_file.by_id(obj.BIMObjectProperties.ifc_definition_id) - parametric = ifcopenshell.util.element.get_psets(product).get("EPset_Parametric") - if not parametric or "Engine" not in parametric or parametric["Engine"] != "Bonsai.DumbLayer2": - return - qto = ifcopenshell.api.run( - "pset.add_qto", ifc_file, should_run_listeners=False, product=product, name="Qto_WallBaseQuantities" - ) - length = obj.dimensions[0] / unit_scale - width = obj.dimensions[1] / unit_scale - height = obj.dimensions[2] / unit_scale - - bm_gross = bmesh.new() - bm_gross.from_mesh(obj.data) - bm_gross.faces.ensure_lookup_table() - - bm_net = bmesh.new() - depsgraph = bpy.context.evaluated_depsgraph_get() - evaluated_mesh = obj.evaluated_get(depsgraph).data - bm_net.from_mesh(evaluated_mesh) - bm_net.faces.ensure_lookup_table() - - gross_footprint_area = sum([f.calc_area() for f in bm_gross.faces if f.normal.z < -0.9]) - net_footprint_area = sum([f.calc_area() for f in bm_net.faces if f.normal.z < -0.9]) - gross_side_area = sum([f.calc_area() for f in bm_gross.faces if f.normal.y > 0.9]) - net_side_area = sum([f.calc_area() for f in bm_net.faces if f.normal.y > 0.9]) - gross_volume = bm_gross.calc_volume() - net_volume = bm_net.calc_volume() - bm_gross.free() - bm_net.free() - - ifcopenshell.api.run( - "pset.edit_qto", - ifc_file, - should_run_listeners=False, - qto=qto, - properties={ - "Length": round(length, 2), - "Width": round(width, 2), - "Height": round(height, 2), - "GrossFootprintArea": round(gross_footprint_area, 2), - "NetFootprintArea": round(net_footprint_area, 2), - "GrossSideArea": round(gross_side_area, 2), - "NetSideArea": round(net_side_area, 2), - "GrossVolume": round(gross_volume, 2), - "NetVolume": round(net_volume, 2), - }, - ) - - class DumbWallPlaner: def regenerate_from_layer(self, usecase_path, ifc_file, settings): if settings["attributes"].get("LayerThickness") is None: @@ -1006,6 +797,8 @@ class DumbWallJoiner: self.recreate_wall(element1, wall1, axis, body) def split(self, wall1, target): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + element1 = tool.Ifc.get_entity(wall1) if not element1: return @@ -1029,6 +822,7 @@ class DumbWallJoiner: r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings ]: opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist()) + opening_matrix.translation *= unit_scale opening_location = opening_matrix.translation _, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"]) if opening_position > cut_percentage: @@ -1040,6 +834,7 @@ class DumbWallJoiner: r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings ]: opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist()) + opening_matrix.translation *= unit_scale opening_location = opening_matrix.translation _, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"]) if opening_position < cut_percentage: @@ -1062,6 +857,8 @@ class DumbWallJoiner: self.recreate_wall(element2, wall2, axis2["reference"], axis2["reference"]) def flip(self, wall1): + 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) @@ -1090,6 +887,7 @@ class DumbWallJoiner: 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"]) diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 257a2e1349..2989184157 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -36,7 +36,8 @@ from bmesh.types import BMVert from mathutils import Vector -def update_window_modifier_representation(context, obj): +def update_window_modifier_representation(context): + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMWindowProperties ifc_file = tool.Ifc.get() @@ -426,11 +427,11 @@ class BIM_OT_add_window(bpy.types.Operator, tool.Ifc.Operator): class AddWindow(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_window" bl_label = "Add Window" + bl_description = "Add Bonsai parametric window to the active IFC element" bl_options = {"REGISTER"} - obj: bpy.props.StringProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMWindowProperties @@ -450,7 +451,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))}, ) - update_window_modifier_representation(context, obj) + update_window_modifier_representation(context) return {"FINISHED"} @@ -502,7 +503,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - update_window_modifier_representation(context, obj) + update_window_modifier_representation(context) pset = tool.Pset.get_element_pset(element, "BBIM_Window") window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list)) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index b7d4312853..cf630a1f0f 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -17,27 +17,48 @@ # along with Bonsai. If not, see . import os +import sys import bpy import bpy.utils.previews import bonsai.tool as tool import bonsai.core.model as core from bonsai.bim.module.model.wall import DumbWallJoiner from bonsai.bim.helper import prop_with_search -from bpy.types import WorkSpaceTool -from bonsai.bim.module.model.data import AuthoringData +from bpy.types import WorkSpaceTool, Menu +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 -custom_icon_previews = None + +def check_display_mode(): + global display_mode + try: + theme = bpy.context.preferences.themes["Default"] + text_color = theme.user_interface.wcol_menu_item.text + background_color = theme.user_interface.wcol_menu_item.outline + print(f"text_color = {text_color}") + print(f"background_color = {background_color}") + if sum(text_color) < 2.6: + display_mode = "lm" + else: + display_mode = "dm" + except: + display_mode = "dm" def load_custom_icons(): global custom_icon_previews + if display_mode is None: + check_display_mode() + icons_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "icons") custom_icon_previews = bpy.utils.previews.new() + + prefix = f"{display_mode}_" + for entry in os.scandir(icons_dir): - if entry.name.endswith(".png"): - name = os.path.splitext(entry.name)[0] + if entry.name.endswith(".png") and entry.name.startswith(prefix): + name = os.path.splitext(entry.name)[0].replace(prefix, "", 1) custom_icon_previews.load(name.upper(), entry.path, "IMAGE") @@ -52,7 +73,7 @@ class BimTool(WorkSpaceTool): bl_space_type = "VIEW_3D" bl_context_mode = "OBJECT" bl_idname = "bim.bim_tool" - bl_label = "BIM Tool" + bl_label = "Multi Object Tool" bl_description = "Create and edit elements by construction class" bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.bim") bl_widget = None @@ -74,6 +95,7 @@ class BimTool(WorkSpaceTool): ("bim.hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}), ("bim.hotkey", {"type": "R", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_R")]}), ("bim.hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}), + ("bim.hotkey", {"type": "U", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_U")]}), ("bim.hotkey", {"type": "V", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_V")]}), ("bim.hotkey", {"type": "X", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_X")]}), ("bim.hotkey", {"type": "Y", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Y")]}), @@ -83,10 +105,17 @@ class BimTool(WorkSpaceTool): ("bim.hotkey", {"type": "P", "value": "PRESS", "ctrl": True}, {"properties": [("hotkey", "C_P")]}), ("bim.hotkey", {"type": "P", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_P")]}), ) + ifc_element_type = "all" - def draw_settings(context, layout, ws_tool): + @classmethod + def draw_settings(cls, context, layout, ws_tool): # Unlike operators, Blender doesn't treat workspace tools as a class, so we'll create our own. - BimToolUI.draw(context, layout, ifc_element_type="all") + if context.scene.BIMGeometryProperties.mode == "ITEM": + EditItemUI.draw(context, layout) + else: + CreateObjectUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) + if context.active_object and context.selected_objects: + EditObjectUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) class WallTool(BimTool): @@ -99,10 +128,6 @@ class WallTool(BimTool): bl_widget = None ifc_element_type = "IfcWallType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class SlabTool(BimTool): bl_space_type = "VIEW_3D" @@ -114,10 +139,6 @@ class SlabTool(BimTool): bl_widget = None ifc_element_type = "IfcSlabType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class DoorTool(BimTool): bl_space_type = "VIEW_3D" @@ -129,10 +150,6 @@ class DoorTool(BimTool): bl_widget = None ifc_element_type = "IfcDoorType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class WindowTool(BimTool): bl_space_type = "VIEW_3D" @@ -144,10 +161,6 @@ class WindowTool(BimTool): bl_widget = None ifc_element_type = "IfcWindowType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class ColumnTool(BimTool): bl_space_type = "VIEW_3D" @@ -159,10 +172,6 @@ class ColumnTool(BimTool): bl_widget = None ifc_element_type = "IfcColumnType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class BeamTool(BimTool): bl_space_type = "VIEW_3D" @@ -174,10 +183,6 @@ class BeamTool(BimTool): bl_widget = None ifc_element_type = "IfcBeamType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class DuctTool(BimTool): bl_space_type = "VIEW_3D" @@ -189,10 +194,6 @@ class DuctTool(BimTool): bl_widget = None ifc_element_type = "IfcDuctSegmentType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class CableCarrierTool(BimTool): bl_space_type = "VIEW_3D" @@ -204,10 +205,6 @@ class CableCarrierTool(BimTool): bl_widget = None ifc_element_type = "IfcCableCarrierSegmentType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class PipeTool(BimTool): bl_space_type = "VIEW_3D" @@ -219,10 +216,6 @@ class PipeTool(BimTool): bl_widget = None ifc_element_type = "IfcPipeSegmentType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - class CableTool(BimTool): bl_space_type = "VIEW_3D" @@ -234,38 +227,92 @@ class CableTool(BimTool): bl_widget = None ifc_element_type = "IfcCableSegmentType" - @classmethod - def draw_settings(cls, context, layout, ws_tool): - BimToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - def add_layout_hotkey_operator(layout, text, hotkey, description, ui_context=""): - modifiers = { - "A": "EVENT_ALT", - "C": "EVENT_CTRL", - "S": "EVENT_SHIFT", - } - modifier, key = hotkey.split("_") + parts = hotkey.split("_") if hotkey else [] + modifier, key = (parts + ["", ""])[:2] - try: - custom_icon = custom_icon_previews[text.upper().replace(" ", "_")].icon_id - except KeyError: - custom_icon = custom_icon_previews["IFC"].icon_id + 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", "")) - if ui_context == "TOOL_HEADER": - op = layout.operator("bim.hotkey", text="", icon_value=custom_icon) - else: - row = layout.row(align=True) - op = row.operator("bim.hotkey", text=text, icon_value=custom_icon) - row.label(text="", icon=modifiers[modifier]) - row.label(text="", icon=f"EVENT_{key}") + row = layout.row(align=True) + op = row.operator("bim.hotkey", 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])) op.hotkey = hotkey - op.description = description + if ui_context == "TOOL_HEADER": + op.description = text + "\n" + description + else: + op.description = description return op -class BimToolUI: +def format_ifc_camel_case(string): + string = string.replace("Ifc", "") + return "".join(" " + char if char.isupper() else char for char in string).strip() + + +class EditItemUI: + @classmethod + def draw(cls, context, layout): + if not ItemData.is_loaded: + ItemData.load() + + cls.layout = layout + row = cls.layout.row() + row.label(text="Item Mode", icon="MESH_DATA") + row = cls.layout.row() + row.label(text="Context: " + ItemData.data["representation_identifier"], icon="SCENE_DATA") + row = cls.layout.row() + row.label(text="Type: " + ItemData.data["representation_type"], icon="OUTLINER_OB_MESH") + cls.layout.menu("BIM_MT_add_representation_item", icon="ADD") + if not (obj := context.active_object) or not tool.Geometry.is_representation_item(obj): + return + for item_attribute in obj.data.BIMMeshProperties.item_attributes: + row = cls.layout.row() + row.prop(item_attribute, "float_value", text=item_attribute.name) + if len(obj.data.BIMMeshProperties.item_attributes): + row = cls.layout.row() + row.operator("bim.update_item_attributes", icon="FILE_REFRESH", text="") + + +class BIM_MT_add_representation_item(Menu): + bl_idname = "BIM_MT_add_representation_item" + bl_label = "Add Item" + + def draw(self, context): + if not ItemData.is_loaded: + ItemData.load() + + if ItemData.data["representation_type"] in ("Tessellation", "Brep", "AdvancedBrep"): + self.layout.operator("bim.add_meshlike_item", icon="MESH_PLANE", text="Mesh Plane").shape = "PLANE" + self.layout.operator("bim.add_meshlike_item", icon="MESH_CUBE", text="Mesh Cube").shape = "CUBE" + self.layout.operator("bim.add_meshlike_item", icon="MESH_CIRCLE", text="Mesh Circle").shape = "CIRCLE" + self.layout.operator("bim.add_meshlike_item", icon="MESH_UVSPHERE", text="Mesh UV Sphere").shape = ( + "UVSPHERE" + ) + self.layout.operator("bim.add_meshlike_item", icon="MESH_ICOSPHERE", text="Mesh Icosphere").shape = ( + "ICOSPHERE" + ) + self.layout.operator("bim.add_meshlike_item", icon="MESH_CYLINDER", text="Mesh Cylinder").shape = "CYLINDER" + + if ItemData.data["representation_type"] in ("SolidModel", "SweptSolid"): + self.layout.operator( + "bim.add_swept_area_solid_item", icon="MESH_CUBE", text="Extruded Area Solid Cube" + ).shape = "CUBE" + self.layout.operator( + "bim.add_swept_area_solid_item", icon="MESH_CYLINDER", text="Extruded Area Solid Cylinder" + ).shape = "CYLINDER" + + +class CreateObjectUI: @classmethod def draw(cls, context, layout, ifc_element_type=None): cls.layout = layout @@ -286,51 +333,77 @@ class BimToolUI: elif AuthoringData.data["ifc_element_type"] != ifc_element_type: AuthoringData.load(ifc_element_type) - if context.region.type == "TOOL_HEADER": - cls.draw_container(context) - cls.draw_type_selection_interface(context) - if context.active_object and context.selected_objects: - cls.draw_edit_object_header_interface(context) + if ifc_element_type and context.region.type == "TOOL_HEADER": + tool_name = ( + "Multi Object Tool" + if ifc_element_type == "all" + else format_ifc_camel_case(ifc_element_type.removesuffix("Type")) + " Tool" + ) + cls.layout.label(text=tool_name, icon="TOOL_SETTINGS") - elif context.region.type in ("UI", "WINDOW"): - cls.draw_container(context) - cls.draw_thumbnail() - cls.draw_type_selection_interface(context) - if context.active_object and context.selected_objects: - cls.draw_edit_object_panel_interface(context) - - if not context.selected_objects: - cls.draw_create_object_interface() + cls.draw_type_manager_launcher(context) + cls.draw_thumbnail() if context.region.type != "TOOL_HEADER" else cls + cls.draw_add_object(context) @classmethod - def draw_create_object_interface(cls): + def draw_container_info(cls, context): + text = AuthoringData.data["default_container"] + if context.region.type == "UI": + text = f"Container: {text}" + + cls.layout.row(align=True).label(text=text, icon="OUTLINER_COLLECTION") + + @classmethod + def draw_type_manager_launcher(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) + if not AuthoringData.data["ifc_classes"]: + if AuthoringData.data["ifc_element_type"]: + row.label(text=f"No {AuthoringData.data['ifc_element_type']} Found", icon="ERROR") + row = cls.layout.row(align=True) + op = row.operator( + "bim.add_default_type", + icon_value=custom_icon_previews["ADD_TYPE"].icon_id, + text=f"Create default {AuthoringData.data['ifc_element_type']}", + ) + op.ifc_element_type = AuthoringData.data["ifc_element_type"] + else: + row.label(text="No Element Types Found", icon="ERROR") + row = cls.layout.row(align=True) + row.operator("bim.launch_type_manager", icon=tool.Blender.TYPE_MANAGER_ICON, text="Launch Type Manager") + + @classmethod + def draw_add_object(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) if not AuthoringData.data["relating_type_id"]: return + if cls.props.ifc_class == "IfcWallType": - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl1", text="RL") + row.prop(data=cls.props, property="rl1", text="Relative Level" if ui_context != "TOOL_HEADER" else "RL") + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop(data=cls.props, property="extrusion_depth", text="Height" if ui_context != "TOOL_HEADER" else "H") + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop(data=cls.props, property="length", text="Length" if ui_context != "TOOL_HEADER" else "L") + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop(data=cls.props, property="x_angle", text="Slope") if ui_context != "TOOL_HEADER" else "A" - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="extrusion_depth", text="Height") - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="length", text="Length") - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="x_angle", text="Angle") elif cls.props.ifc_class in ("IfcSlabType", "IfcRampType", "IfcRoofType"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="x_angle", text="Angle") - elif cls.props.ifc_class in ("IfcColumnType", "IfcBeamType", "IfcMemberType"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="cardinal_point", text="Axis") + row.prop( + data=cls.props, property="x_angle", text="Slope" if ui_context != "TOOL_HEADER" else "A", icon="FILE_3D" + ) + + elif cls.props.ifc_class in ("IfcColumnType", "IfcMemberType"): + 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 cls.props.ifc_class in ("IfcBeamType"): + 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") - row = cls.layout.row(align=True) - label = "Height" if cls.props.ifc_class == "IfcColumn" else "Length" - row.prop(data=cls.props, property="extrusion_depth", text=label) elif cls.props.ifc_class in ("IfcDoorType", "IfcDoorStyle"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl1", text="RL") + row.prop(data=cls.props, property="rl1", text="Relative Level" if ui_context != "TOOL_HEADER" else "RL") + elif cls.props.ifc_class in ( "IfcWindowType", "IfcWindowStyle", @@ -341,498 +414,39 @@ class BimToolUI: "IfcCableCarrierSegmentType", "IfcCableSegmentType", ): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl2", text="RL") + row.prop( + data=cls.props, property="rl2", text="Relative Level (rl2)" if ui_context != "TOOL_HEADER" else "RL" + ) + + ### this neeeds to move elif cls.props.ifc_class in ("IfcSpaceType"): - add_layout_hotkey_operator(cls.layout, "Generate", "S_G", bpy.ops.bim.generate_space.__doc__) + add_layout_hotkey_operator(cls.layout, "Generate", "S_G", bpy.ops.bim.generate_space.__doc__, ui_context) + ### else: row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl_mode", text="RL") + row.prop(data=cls.props, property="rl_mode", text="RL Mode" if ui_context != "TOOL_HEADER" else "RL") - @classmethod - def draw_edit_object_panel_interface(cls, context): - ui_context = str(context.region.type) - if AuthoringData.data["active_material_usage"] == "LAYER2": - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="extrusion_depth", text="Height:") - op = row.operator("bim.change_extrusion_depth", icon="FILE_REFRESH", text="") - op.depth = cls.props.extrusion_depth - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="length", text="Length:") - op = row.operator("bim.change_layer_length", icon="FILE_REFRESH", text="") - op.length = cls.props.length - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="x_angle", text="Angle:") - op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") - op.x_angle = cls.props.x_angle - - cls.layout.separator() - - add_layout_hotkey_operator( - cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_wall.__doc__ + "\n⇧ G", ui_context - ) - - cls.layout.separator() - - add_layout_hotkey_operator( - cls.layout, - "Extend", - "S_E", - "Extends/reduces element to 3D cursor\n⇧ E", - ui_context, - ) - add_layout_hotkey_operator( - cls.layout, - "Butt", - "S_T", - "Intersects two non-parallel elements to a butt corner junction\n⇧ T", - ui_context, - ) - add_layout_hotkey_operator( - cls.layout, - "Mitre", - "S_Y", - "Intersects two non-parallel elements to a mitred corner junction\n⇧ Y", - ui_context, - ) - - row = cls.layout.row(align=True) - row.operator( - "bim.unjoin_walls", text="Unjoin Walls", icon_value=custom_icon_previews["UNJOIN_WALLS"].icon_id - ) - cls.layout.separator() - - add_layout_hotkey_operator(cls.layout, "Merge", "S_M", bpy.ops.bim.merge_wall.__doc__ + "\n⇧ M", ui_context) - add_layout_hotkey_operator(cls.layout, "Split", "S_K", bpy.ops.bim.split_wall.__doc__ + "\n⇧ K", ui_context) - add_layout_hotkey_operator( - cls.layout, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__ + "\n⇧ R", ui_context - ) - add_layout_hotkey_operator(cls.layout, "Flip", "S_F", bpy.ops.bim.flip_wall.__doc__ + "\n⇧ F", ui_context) - - # row.operator("bim.unjoin_walls", icon="X", text="") - - elif AuthoringData.data["active_material_usage"] == "LAYER3": - if len(context.selected_objects) == 1: - add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "", ui_context) - elif "LAYER2" in AuthoringData.data["selected_material_usages"]: - add_layout_hotkey_operator(cls.layout, "Extend Wall To Slab", "S_E", "", ui_context) - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="x_angle", text="Angle") - op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") - op.x_angle = cls.props.x_angle - - elif AuthoringData.data["active_material_usage"] == "PROFILE": - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="cardinal_point", text="Axis") - op = row.operator("bim.change_cardinal_point", icon="FILE_REFRESH", text="") - op.cardinal_point = int(cls.props.cardinal_point) - - row = cls.layout.row(align=True) - label = ( - "Height" if AuthoringData.data["active_class"] in ("IfcColumn", "IfcColumnStandardCase") else "Length" - ) - row.prop(data=cls.props, property="extrusion_depth", text=label) - op = row.operator("bim.change_profile_depth", icon="FILE_REFRESH", text="") - op.depth = cls.props.extrusion_depth - - add_layout_hotkey_operator( - cls.layout, - "Extend", - "S_E", - "", - ui_context, - ) - add_layout_hotkey_operator(cls.layout, "Flip", "S_F", bpy.ops.bim.flip_object.__doc__, ui_context) - - if AuthoringData.data["active_class"] in ( - "IfcCableCarrierSegment", - "IfcCableSegment", - "IfcDuctSegment", - "IfcPipeSegment", - ): - - add_layout_hotkey_operator(cls.layout, "Add Fitting", "S_Y", "", ui_context) - cls.layout.operator("bim.mep_add_bend") - cls.layout.operator("bim.mep_add_transition") - cls.layout.operator("bim.mep_add_obstruction") - - else: - add_layout_hotkey_operator( - cls.layout, - "Edit Axis", - "A_E", - "", - ui_context, - ) - add_layout_hotkey_operator( - cls.layout, - "Butt", - "S_T", - "", - ui_context, - ) - add_layout_hotkey_operator( - cls.layout, - "Mitre", - "S_Y", - "", - ui_context, - ) - add_layout_hotkey_operator(cls.layout, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__, ui_context) - add_layout_hotkey_operator( - cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_profile.__doc__, ui_context - ) - row.operator("bim.extend_profile", icon="X", text="").join_type = "" - - elif ( - tool.Model.is_parametric_railing_active() and not context.active_object.BIMRailingProperties.is_editing_path - ): - # NOTE: should be above "active_representation_type" = "SweptSolid" check - # because it could be a SweptSolid too - row = cls.layout.row(align=True) - row.label(text="", icon="EVENT_TAB") - row.operator("bim.enable_editing_railing_path", text="Edit Railing Path") - - elif AuthoringData.data["active_class"] in ( - "IfcWindow", - "IfcWindowStandardCase", - "IfcDoor", - "IfcDoorStandardCase", - ): - if AuthoringData.data["active_class"] in ("IfcWindow", "IfcWindowStandardCase"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl2", text="RL") - elif AuthoringData.data["active_class"] in ("IfcDoor", "IfcDoorStandardCase"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl1", text="RL") - - add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_fill.__doc__, ui_context) - add_layout_hotkey_operator(cls.layout, "Flip", "S_F", "", ui_context) - - elif AuthoringData.data["active_representation_type"] == "SweptSolid": - if not tool.Model.is_parametric_window_active() and not tool.Model.is_parametric_door_active(): - add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "", ui_context) - - elif AuthoringData.data["active_class"] in ("IfcSpace",): - add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.generate_space.__doc__, ui_context) - - elif tool.Model.is_parametric_roof_active() and not context.active_object.BIMRoofProperties.is_editing_path: - row = cls.layout.row(align=True) - row.label(text="", icon="EVENT_TAB") - row.operator("bim.enable_editing_roof_path", text="Edit Roof Path") - - if context.region.type != "TOOL_HEADER" and PortData.data["total_ports"] > 0: - add_layout_hotkey_operator( - cls.layout, "Regen MEP", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context - ) - cls.layout.operator("bim.mep_connect_elements") - - row = cls.layout.row(align=True) - if len(context.selected_objects) > 1: - row.operator("bim.add_opening", text="Apply Void", icon_value=custom_icon_previews["VOID"].icon_id) - else: - row.operator( - "bim.add_potential_opening", text="Add Void", icon_value=custom_icon_previews["ADD_VOID"].icon_id - ) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_O") - - if AuthoringData.data["is_voidable_element"]: - if AuthoringData.data["has_visible_openings"]: - row = cls.layout.row(align=True) - row.operator("bim.edit_openings", icon="CHECKMARK", text="") - row.operator("bim.hide_openings", icon="CANCEL", text="") - - if AuthoringData.data["active_class"] in ("IfcOpeningElement",): - row.operator("bim.edit_openings", icon="CHECKMARK", text="") - row.operator("bim.hide_openings", icon="CANCEL", text="") - if len(context.selected_objects) == 2: - row = cls.layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_L") - row.operator("bim.clone_opening", text="Clone Opening") - - cls.layout.row(align=True).label(text="Align") - add_layout_hotkey_operator(cls.layout, "Exterior", "S_X", "", ui_context) - add_layout_hotkey_operator(cls.layout, "Centerline", "S_C", "", ui_context) - add_layout_hotkey_operator(cls.layout, "Interior", "S_V", "", ui_context) - add_layout_hotkey_operator(cls.layout, "Mirror", "S_M", bpy.ops.bim.mirror_elements.__doc__, ui_context) - - cls.layout.row(align=True).label(text="Mode") - add_layout_hotkey_operator(cls.layout, "Void", "A_O", "Toggle openings", ui_context) - add_layout_hotkey_operator(cls.layout, "Decomposition", "A_D", "Select decomposition", ui_context) - - cls.layout.row(align=True).label(text="Aggregation") - add_layout_hotkey_operator(cls.layout, "Assign", "C_P", bpy.ops.bim.aggregate_assign_object.__doc__, ui_context) - add_layout_hotkey_operator( - cls.layout, "Unassign", "A_P", bpy.ops.bim.aggregate_unassign_object.__doc__, ui_context - ) - - cls.layout.row(align=True).label(text="Qto") - add_layout_hotkey_operator( - cls.layout, "Perform Quantity Take-off", "S_Q", bpy.ops.bim.perform_quantity_take_off.__doc__, ui_context - ) - - @classmethod - def draw_edit_object_header_interface(cls, context): - ui_context = str(context.region.type) - if AuthoringData.data["active_material_usage"] == "LAYER2": - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="extrusion_depth", text="Height:") - op = row.operator("bim.change_extrusion_depth", icon="FILE_REFRESH", text="") - op.depth = cls.props.extrusion_depth - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="length", text="Length:") - op = row.operator("bim.change_layer_length", icon="FILE_REFRESH", text="") - op.length = cls.props.length - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="x_angle", text="Angle:") - op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") - op.x_angle = cls.props.x_angle - - row = cls.layout.row() - add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.recalculate_wall.__doc__ + "\n⇧ G", ui_context) - - row = cls.layout.row(align=True) - add_layout_hotkey_operator( - row, - "Extend", - "S_E", - "Extends/reduces element to 3D cursor\n⇧ E", - ui_context, - ) - add_layout_hotkey_operator( - row, - "Butt", - "S_T", - "Intersects two non-parallel elements to a butt corner junction\n⇧ T", - ui_context, - ) - add_layout_hotkey_operator( - row, - "Mitre", - "S_Y", - "Intersects two non-parallel elements to a mitred corner junction\n⇧ Y", - ui_context, - ) - row.operator("bim.unjoin_walls", text="", icon_value=custom_icon_previews["UNJOIN_WALLS"].icon_id) - - row = cls.layout.row(align=True) - add_layout_hotkey_operator(row, "Merge", "S_M", bpy.ops.bim.merge_wall.__doc__ + "\n⇧ M", ui_context) - add_layout_hotkey_operator(row, "Split", "S_K", bpy.ops.bim.split_wall.__doc__ + "\n⇧ K", ui_context) - add_layout_hotkey_operator(row, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__ + "\n⇧ R", ui_context) - add_layout_hotkey_operator(row, "Flip", "S_F", bpy.ops.bim.flip_wall.__doc__ + "\n⇧ F", ui_context) - - elif AuthoringData.data["active_material_usage"] == "LAYER3": - if len(context.selected_objects) == 1: - add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "", ui_context) - elif "LAYER2" in AuthoringData.data["selected_material_usages"]: - add_layout_hotkey_operator(cls.layout, "Extend Wall To Slab", "S_E", "", ui_context) - - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="x_angle", text="Angle") - op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") - op.x_angle = cls.props.x_angle - - elif AuthoringData.data["active_material_usage"] == "PROFILE": - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="cardinal_point", text="Axis") - op = row.operator("bim.change_cardinal_point", icon="FILE_REFRESH", text="") - op.cardinal_point = int(cls.props.cardinal_point) - - row = cls.layout.row(align=True) - label = ( - "Height" if AuthoringData.data["active_class"] in ("IfcColumn", "IfcColumnStandardCase") else "Length" - ) - row.prop(data=cls.props, property="extrusion_depth", text=label) - op = row.operator("bim.change_profile_depth", icon="FILE_REFRESH", text="") - op.depth = cls.props.extrusion_depth - - add_layout_hotkey_operator( - cls.layout, - "Extend", - "S_E", - "", - ui_context, - ) - add_layout_hotkey_operator(cls.layout, "Flip", "S_F", bpy.ops.bim.flip_object.__doc__, ui_context) - - if AuthoringData.data["active_class"] in ( - "IfcCableCarrierSegment", - "IfcCableSegment", - "IfcDuctSegment", - "IfcPipeSegment", - ): - - add_layout_hotkey_operator(cls.layout, "Add Fitting", "S_Y", "", ui_context) - if context.region.type != "TOOL_HEADER": - cls.layout.operator("bim.mep_add_bend") - cls.layout.operator("bim.mep_add_transition") - cls.layout.operator("bim.mep_add_obstruction") - - else: - add_layout_hotkey_operator( - cls.layout, - "Edit Axis", - "A_E", - "", - ui_context, - ) - add_layout_hotkey_operator( - cls.layout, - "Butt", - "S_T", - "", - ui_context, - ) - add_layout_hotkey_operator( - cls.layout, - "Mitre", - "S_Y", - "", - ui_context, - ) - add_layout_hotkey_operator(cls.layout, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__, ui_context) - add_layout_hotkey_operator( - cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_profile.__doc__, ui_context - ) - row.operator("bim.extend_profile", icon="X", text="").join_type = "" - - elif ( - tool.Model.is_parametric_railing_active() and not context.active_object.BIMRailingProperties.is_editing_path - ): - # NOTE: should be above "active_representation_type" = "SweptSolid" check - # because it could be a SweptSolid too - row = cls.layout.row(align=True) - row.label(text="", icon="EVENT_TAB") - row.operator("bim.enable_editing_railing_path", text="Edit Railing Path") - - elif AuthoringData.data["active_class"] in ( - "IfcWindow", - "IfcWindowStandardCase", - "IfcDoor", - "IfcDoorStandardCase", - ): - if AuthoringData.data["active_class"] in ("IfcWindow", "IfcWindowStandardCase"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl2", text="RL") - elif AuthoringData.data["active_class"] in ("IfcDoor", "IfcDoorStandardCase"): - row = cls.layout.row(align=True) - row.prop(data=cls.props, property="rl1", text="RL") - - add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_fill.__doc__, ui_context) - add_layout_hotkey_operator(cls.layout, "Flip", "S_F", "", ui_context) - - elif AuthoringData.data["active_representation_type"] == "SweptSolid": - if not tool.Model.is_parametric_window_active() and not tool.Model.is_parametric_door_active(): - add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "", ui_context) - - elif AuthoringData.data["active_class"] in ("IfcSpace",): - add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.generate_space.__doc__, ui_context) - - elif tool.Model.is_parametric_roof_active() and not context.active_object.BIMRoofProperties.is_editing_path: - row = cls.layout.row(align=True) - row.label(text="", icon="EVENT_TAB") - row.operator("bim.enable_editing_roof_path", text="Edit Roof Path") - - if context.region.type != "TOOL_HEADER" and PortData.data["total_ports"] > 0: - add_layout_hotkey_operator( - cls.layout, "Regen MEP", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context - ) - cls.layout.operator("bim.mep_connect_elements") - - row = cls.layout.row(align=True) - if len(context.selected_objects) > 1: - row.operator("bim.add_opening", text="", icon_value=custom_icon_previews["VOID"].icon_id) - else: - row.operator("bim.add_potential_opening", text="", icon_value=custom_icon_previews["ADD_VOID"].icon_id) - - if AuthoringData.data["is_voidable_element"]: - if AuthoringData.data["has_visible_openings"]: - row = cls.layout.row(align=True) - row.operator("bim.edit_openings", icon="CHECKMARK", text="") - row.operator("bim.hide_openings", icon="CANCEL", text="") - - if AuthoringData.data["active_class"] in ("IfcOpeningElement",): - row.operator("bim.edit_openings", icon="CHECKMARK", text="") - row.operator("bim.hide_openings", icon="CANCEL", text="") - if len(context.selected_objects) == 2: - row = cls.layout.row(align=True) - row.label(text="", icon="EVENT_SHIFT") - row.label(text="", icon="EVENT_L") - row.operator("bim.clone_opening", text="Clone Opening") - - row = cls.layout.row(align=True) - add_layout_hotkey_operator(row, "Exterior", "S_X", "", ui_context) - add_layout_hotkey_operator(row, "Centerline", "S_C", "", ui_context) - add_layout_hotkey_operator(row, "Interior", "S_V", "", ui_context) - add_layout_hotkey_operator(row, "Mirror", "S_M", bpy.ops.bim.mirror_elements.__doc__, ui_context) - - row = cls.layout.row(align=True) - add_layout_hotkey_operator(row, "Void", "A_O", "Toggle openings", ui_context) - add_layout_hotkey_operator(row, "Decomposition", "A_D", "Select decomposition", ui_context) - - row = cls.layout.row(align=True) - add_layout_hotkey_operator(row, "Assign", "C_P", bpy.ops.bim.aggregate_assign_object.__doc__, ui_context) - add_layout_hotkey_operator(row, "Unassign", "A_P", bpy.ops.bim.aggregate_unassign_object.__doc__, ui_context) - - row = cls.layout.row() - add_layout_hotkey_operator( - row, "Perform Quantity Take-off", "S_Q", bpy.ops.bim.perform_quantity_take_off.__doc__, ui_context - ) - - @classmethod - def draw_container(cls, context): - text = AuthoringData.data["default_container"] - if context.region.type == "UI": - text = f"Container: {text}" - - cls.layout.row(align=True).label(text=text, icon="OUTLINER_COLLECTION") - - @classmethod - def draw_type_selection_interface(cls, context): - # shared by both sidebar and header - ui_context = str(context.region.type) - row = cls.layout.row(align=True) if AuthoringData.data["ifc_classes"]: - if ui_context == "UI": - text = "Add" - else: - text = "" if not AuthoringData.data["ifc_element_type"]: - - prop_with_search(row, cls.props, "ifc_class", text="") - row.operator("bim.add_constr_type_instance", text=text, icon_value=custom_icon_previews["ADD"].icon_id) - - row = cls.layout.row(align=True) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + prop_with_search(row, cls.props, "ifc_class", text="Type Class" if ui_context != "TOOL_HEADER" else "") if AuthoringData.data["relating_type_id"]: - prop_with_search(row, cls.props, "relating_type_id", text="") - row.operator("bim.add_constr_type_instance", text=text, icon_value=custom_icon_previews["ADD"].icon_id) - + row = cls.layout.row(align=True) + row.operator( + "bim.launch_type_manager", + icon=tool.Blender.TYPE_MANAGER_ICON, + text="Type: " + AuthoringData.data["relating_type_name"], + ) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Add", "S_A", bpy.ops.bim.add_constr_type_instance.__doc__, ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + ( + add_layout_hotkey_operator(row, "Draw", "S_P", bpy.ops.bim.draw_polyline_wall.__doc__, ui_context) + if cls.props.ifc_class == "IfcWallType" + else row + ) else: row.label(text="No Construction Type", icon="FILE_3D") - row.operator("bim.launch_type_manager", icon=tool.Blender.TYPE_MANAGER_ICON, text="") - else: - if AuthoringData.data["ifc_element_type"]: - row.label(text=f"No {AuthoringData.data['ifc_element_type']} Found", icon="ERROR") - row = cls.layout.row() - row.prop(cls.props, "type_name") - row = cls.layout.row(align=True) - op = row.operator( - "bim.add_default_type", - icon_value=custom_icon_previews["ADD_TYPE"].icon_id, - text=f"Create {AuthoringData.data['ifc_element_type']}", - ) - op.ifc_element_type = AuthoringData.data["ifc_element_type"] - row.operator("bim.launch_type_manager", icon=tool.Blender.TYPE_MANAGER_ICON, text="") - else: - row.label(text="No Element Types Found", icon="ERROR") - row.operator("bim.launch_type_manager", icon=tool.Blender.TYPE_MANAGER_ICON, text="Launch Type Manager") @classmethod def draw_thumbnail(cls): @@ -846,9 +460,344 @@ class BimToolUI: op.ifc_class = cls.props.ifc_class +class EditObjectUI: + @classmethod + def draw(cls, context, layout, ifc_element_type=None): + cls.layout = layout + cls.props = context.scene.BIMModelProperties + + row = cls.layout.row(align=True) + row.separator() + if not tool.Ifc.get(): + row.label(text="No IFC Project", icon="ERROR") + return + + if not PortData.is_loaded: + PortData.load() + + 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 context.region.type == "TOOL_HEADER": + text = format_ifc_camel_case(AuthoringData.data["active_class"]) + layout.label(text=f"{text} Edit Tools:", icon="RESTRICT_SELECT_OFF") + cls.draw_parameter_adjustments(context) + row = cls.draw_operations(context) + cls.draw_void(context, row) + cls.draw_align(context) + cls.draw_aggregation(context) + cls.draw_qto(context) + cls.draw_modes(context) + + if context.region.type in ("UI", "WINDOW"): + text = format_ifc_camel_case(AuthoringData.data["active_class"]) + layout.label(text=f"{text} Edit Tools:", icon="RESTRICT_SELECT_OFF") + cls.draw_parameter_adjustments(context) + row = cls.draw_operations(context) + cls.draw_void(context, row) + cls.draw_align(context) + cls.draw_aggregation(context) + cls.draw_qto(context) + cls.draw_modes(context) + + @classmethod + def draw_parameter_adjustments(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) + row.label(text="Parameter Adjustments") if ui_context != "TOOL_HEADER" else row + row = cls.layout.row(align=True) + + if AuthoringData.data["active_material_usage"] == "LAYER2": + row.prop(data=cls.props, property="extrusion_depth", text="Height" if ui_context != "TOOL_HEADER" else "H") + op = row.operator("bim.change_extrusion_depth", icon="FILE_REFRESH", text="") + op.depth = cls.props.extrusion_depth + + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop(data=cls.props, property="length", text="Length" if ui_context != "TOOL_HEADER" else "L") + op = row.operator("bim.change_layer_length", icon="FILE_REFRESH", text="") + op.length = cls.props.length + + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop(data=cls.props, property="x_angle", text="Slope" if ui_context != "TOOL_HEADER" else "A") + op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") + op.x_angle = cls.props.x_angle + + elif AuthoringData.data["active_material_usage"] == "LAYER3": + row.prop(data=cls.props, property="x_angle", text="Angle" if ui_context != "TOOL_HEADER" else "A") + op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") + op.x_angle = cls.props.x_angle + + elif AuthoringData.data["active_material_usage"] == "PROFILE": + row.prop(data=cls.props, property="cardinal_point", text="Axis" if ui_context != "TOOL_HEADER" else "") + op = row.operator("bim.change_cardinal_point", icon="FILE_REFRESH", text="") + 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" + ) + row.prop( + data=cls.props, property="extrusion_depth", text=label if ui_context != "TOOL_HEADER" else label[0] + ) + op = row.operator("bim.change_profile_depth", icon="FILE_REFRESH", text="") + op.depth = cls.props.extrusion_depth + + elif AuthoringData.data["active_class"] in ( + "IfcWindow", + "IfcWindowStandardCase", + "IfcDoor", + "IfcDoorStandardCase", + ): + if AuthoringData.data["active_class"] in ("IfcWindow", "IfcWindowStandardCase"): + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop( + data=cls.props, property="rl2", text="Relative Level (rl2)" if ui_context != "TOOL_HEADER" else "RL" + ) + elif AuthoringData.data["active_class"] in ("IfcDoor", "IfcDoorStandardCase"): + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop( + data=cls.props, property="rl1", text="Relative Level (rl1)" if ui_context != "TOOL_HEADER" else "RL" + ) + + @classmethod + def draw_operations(cls, context): + ui_context = str(context.region.type) + + row = cls.layout.row(align=True) + row.separator() + row.label(text="Operations") if ui_context != "TOOL_HEADER" else row + cls.draw_regen_operations(row) + + if AuthoringData.data["active_material_usage"] == "LAYER2": + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + 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 = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Unjoin Walls", "S_U", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Merge", "S_M", "Merge selected Elements", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator( + row, "Split", "S_K", "Split selected Element into two Elements at the cursor location", ui_context + ) + 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) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator( + row, "Flip", "S_F", "Flip Element about its local axes, keep the position", ui_context + ) + + 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) + + elif AuthoringData.data["active_material_usage"] == "PROFILE": + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Extend", "S_E", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Flip", "S_F", bpy.ops.bim.flip_object.__doc__, ui_context) + + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + if AuthoringData.data["active_class"] in ( + "IfcCableCarrierSegment", + "IfcCableSegment", + "IfcDuctSegment", + "IfcPipeSegment", + ): + add_layout_hotkey_operator(row, "Add Fitting", "S_Y", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator( + "bim.mep_add_bend", + text="Add Bend" if ui_context != "TOOL_HEADER" else "", + icon_value=custom_icon_previews["ADD_BEND"].icon_id, + ) + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator( + "bim.mep_add_transition", + text="Add Transition" if ui_context != "TOOL_HEADER" else "", + icon_value=custom_icon_previews["ADD_TRANSITION"].icon_id, + ) + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator( + "bim.mep_add_obstruction", + text="Add Obstruction" if ui_context != "TOOL_HEADER" else "", + icon_value=custom_icon_previews["IFC"].icon_id, + ) + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + + else: + add_layout_hotkey_operator(row, "Edit Axis", "A_E", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Butt", "S_T", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Mitre", "S_Y", "", ui_context) + 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) + + if PortData.data["total_ports"] > 0: + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator( + "bim.mep_connect_elements", + text="Connect MEP Elements" if ui_context != "TOOL_HEADER" else "", + icon_value=custom_icon_previews["CONNECT_MEP_ELEMENTS"].icon_id, + ) + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + row.label(text="", icon="BLANK1") if ui_context != "TOOL_HEADER" else row + + return row + + @classmethod + def draw_regen_operations(cls, row): + custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id + + if AuthoringData.data["active_material_usage"] == "LAYER2": + op = row.operator("bim.hotkey", text="", icon_value=custom_icon) + description = f"{bpy.ops.bim.recalculate_wall.__doc__}\n\nHotkey: S G" + op.hotkey = "S_G" + op.description = description.strip() + elif AuthoringData.data["active_material_usage"] == "PROFILE": + if AuthoringData.data["active_class"] in ( + "IfcCableCarrierSegment", + "IfcCableSegment", + "IfcDuctSegment", + "IfcPipeSegment", + ): + pass + else: + op = row.operator("bim.hotkey", text="", icon_value=custom_icon) + description = f"{bpy.ops.bim.recalculate_profile.__doc__}\n\nHotkey: S G" + op.hotkey = "S_G" + op.description = description.strip() + + if PortData.data["total_ports"] > 0: + op = row.operator("bim.hotkey", text="", icon_value=custom_icon) + description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G" + op.hotkey = "S_G" + op.description = description.strip() + + @classmethod + def draw_void(cls, context, row): + ui_context = str(context.region.type) + + if len(context.selected_objects) > 1: + op_text = "Apply Void" if ui_context != "TOOL_HEADER" else "" + op_icon = custom_icon_previews["APPLY_VOID"].icon_id + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator("bim.add_opening", text=op_text, icon_value=op_icon) + else: + op_text = "Add Void" if ui_context != "TOOL_HEADER" else "" + op_icon = custom_icon_previews["ADD_VOID"].icon_id + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator("bim.add_potential_opening", text=op_text, icon_value=op_icon) + if ui_context != "TOOL_HEADER": + row.label(text="", icon="EVENT_SHIFT") + row.label(text="", icon="EVENT_O") + + if AuthoringData.data["is_voidable_element"]: + if AuthoringData.data["has_visible_openings"]: + row = cls.layout.row(align=True) + row.operator("bim.edit_openings", icon="CHECKMARK", text="") + row.operator("bim.hide_openings", icon="CANCEL", text="") + + if AuthoringData.data["active_class"] in ("IfcOpeningElement",): + row = cls.layout.row(align=True) + row.operator("bim.edit_openings", icon="CHECKMARK", text="") + row.operator("bim.hide_openings", icon="CANCEL", text="") + if len(context.selected_objects) == 2: + row = cls.layout.row(align=True) + row.label(text="", icon="EVENT_SHIFT") + row.label(text="", icon="EVENT_L") + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator("bim.clone_opening", text="Clone Opening") + + @classmethod + def draw_align(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) + row.separator() + row.label(text="Align") if ui_context != "TOOL_HEADER" else row + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Exterior", "S_X", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Centreline", "S_C", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Interior", "S_V", "", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Mirror", "S_M", bpy.ops.bim.mirror_elements.__doc__, ui_context) + + @classmethod + def draw_aggregation(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) + row.separator() + row.label(text="Aggregation") if ui_context != "TOOL_HEADER" else row + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Assign", "C_P", bpy.ops.bim.aggregate_assign_object.__doc__, ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Unassign", "A_P", bpy.ops.bim.aggregate_unassign_object.__doc__, ui_context) + + @classmethod + def draw_qto(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) + row.separator() + row.label(text="Quantity Take-off") if ui_context != "TOOL_HEADER" else row + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator( + row, "Perform Quantity Take-off", "S_Q", bpy.ops.bim.perform_quantity_take_off.__doc__, ui_context + ) + + @classmethod + def draw_modes(cls, context): + ui_context = str(context.region.type) + row = cls.layout.row(align=True) + row.separator() + row.label(text="Mode") if ui_context != "TOOL_HEADER" else row + + if AuthoringData.data["active_material_usage"] == "LAYER3": + if len(context.selected_objects) == 1: + 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 + ): + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.operator( + "bim.enable_editing_railing_path", + text="Edit Path" if ui_context != "TOOL_HEADER" else "", + icon_value=custom_icon_previews["EDIT_RAILING_PATH"].icon_id, + ) + elif AuthoringData.data["active_material_usage"] == "PROFILE": + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Edit Axis", "A_E", "", ui_context) + + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Toggle Openings", "A_O", "Toggle openings", ui_context) + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Decomposition", "A_D", "Select decomposition", ui_context) + + class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.hotkey" - bl_label = "Hotkey" + bl_label = "BIM Hotkey" bl_options = {"REGISTER", "UNDO"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -900,7 +849,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): row.prop(self, "z") def hotkey_S_A(self): - bpy.ops.bim.add_constr_type_instance() + bpy.ops.bim.add_constr_type_instance("INVOKE_DEFAULT") def hotkey_S_Q(self): if not bpy.context.selected_objects: @@ -923,9 +872,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return if self.active_material_usage == "LAYER2": if bpy.ops.bim.align_wall.poll(): - bpy.ops.bim.align_wall(align_type="CENTERLINE") + bpy.ops.bim.align_wall(align_type="Centreline") else: - bpy.ops.bim.align_product(align_type="CENTERLINE") + bpy.ops.bim.align_product(align_type="Centreline") def hotkey_S_E(self): if not bpy.context.selected_objects: @@ -1095,7 +1044,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): def hotkey_S_V(self): if not bpy.context.selected_objects: return - if self.active_material_usage == "LAYER2": + elif self.active_material_usage == "LAYER2": bpy.ops.bim.align_wall(align_type="INTERIOR") else: bpy.ops.bim.align_product(align_type="POSITIVE") @@ -1135,13 +1084,21 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): self.props.z = self.z def hotkey_S_P(self): - bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT") + mode = bpy.context.mode + current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode) + if current_tool.idname == "bim.wall_tool": + bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT") def hotkey_S_L(self): if AuthoringData.data["active_class"] in ("IfcOpeningElement",): if len(bpy.context.selected_objects) == 2: bpy.ops.bim.clone_opening() + def hotkey_S_U(self): + if not bpy.context.selected_objects: + return + bpy.ops.bim.unjoin_walls() + def hotkey_A_D(self): if not bpy.context.selected_objects: return @@ -1162,5 +1119,14 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.show_openings() +custom_icon_previews = None +display_mode = None + LIST_OF_TOOLS = [cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool])] TOOLS_TO_CLASSES_MAP = {cls.bl_idname: cls.ifc_element_type for cls in BimTool.__subclasses__()} +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/patch/operator.py b/src/bonsai/bonsai/bim/module/patch/operator.py index 60876c4d4d..cdec1a8b73 100644 --- a/src/bonsai/bonsai/bim/module/patch/operator.py +++ b/src/bonsai/bonsai/bim/module/patch/operator.py @@ -70,8 +70,11 @@ class ExecuteIfcPatch(bpy.types.Operator): @classmethod def poll(cls, context): - input_file = context.scene.BIMPatchProperties.ifc_patch_input - return input_file or context.scene.BIMPatchProperties.should_load_from_memory + props = context.scene.BIMPatchProperties + if not props.should_load_from_memory and not props.ifc_patch_input: + cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.") + return False + return True def execute(self, context): props = context.scene.BIMPatchProperties diff --git a/src/bonsai/bonsai/bim/module/profile/__init__.py b/src/bonsai/bonsai/bim/module/profile/__init__.py index d06bc1c78c..a9a4895884 100644 --- a/src/bonsai/bonsai/bim/module/profile/__init__.py +++ b/src/bonsai/bonsai/bim/module/profile/__init__.py @@ -30,7 +30,6 @@ classes = ( operator.EnableEditingArbitraryProfile, operator.EnableEditingProfile, operator.LoadProfiles, - operator.PurgeUnusedProfiles, operator.RemoveProfileDef, prop.Profile, prop.BIMProfileProperties, diff --git a/src/bonsai/bonsai/bim/module/profile/data.py b/src/bonsai/bonsai/bim/module/profile/data.py index 2402192aad..2ea97a9a20 100644 --- a/src/bonsai/bonsai/bim/module/profile/data.py +++ b/src/bonsai/bonsai/bim/module/profile/data.py @@ -29,6 +29,7 @@ def refresh(): class ProfileData: data = {} + failed_previews: set[int] = set() preview_collection = bpy.utils.previews.new() is_loaded = False diff --git a/src/bonsai/bonsai/bim/module/profile/operator.py b/src/bonsai/bonsai/bim/module/profile/operator.py index 432f137b1c..cb7cd27491 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.util.element import bonsai.bim.helper import bonsai.tool as tool import bonsai.bim.module.model.profile as model_profile @@ -68,7 +69,28 @@ class RemoveProfileDef(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = context.scene.BIMProfileProperties current_index = props.active_profile_index - ifcopenshell.api.run("profile.remove_profile", tool.Ifc.get(), profile=tool.Ifc.get().by_id(self.profile)) + + ifc_file = tool.Ifc.get() + profile = ifc_file.by_id(self.profile) + + # Save user from creating invalid IFC. + inverse_classes = {i.is_a() for i in ifc_file.get_inverse(profile)} + schema = ifcopenshell.schema_by_name(ifc_file.schema) + + # Allow removing profile with IfcProfileProperties. + for inverse_class in inverse_classes.copy(): + declaration = schema.declaration_by_name(inverse_class) + if declaration._is("IfcProfileProperties"): + inverse_classes.remove(inverse_class) + + if inverse_classes: + error_msg = "Cannot remove profile as it's still part of other IFC entities:" + for inverse_class in inverse_classes: + error_msg += "\n- " + inverse_class + self.report({"ERROR"}, error_msg) + return {"CANCELLED"} + + ifcopenshell.api.run("profile.remove_profile", ifc_file, profile=profile) bpy.ops.bim.load_profiles() # preserve selected index if possible @@ -76,17 +98,18 @@ class RemoveProfileDef(bpy.types.Operator, tool.Ifc.Operator): props.active_profile_index = min(current_index, len(props.profiles) - 1) -class EnableEditingProfile(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingProfile(bpy.types.Operator): bl_idname = "bim.enable_editing_profile" bl_label = "Enable Editing Profile" bl_options = {"REGISTER", "UNDO"} profile: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): props = context.scene.BIMProfileProperties 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 + return {"FINISHED"} class DisableEditingProfile(bpy.types.Operator): @@ -128,6 +151,7 @@ class AddProfileDef(bpy.types.Operator, tool.Ifc.Operator): profile = ifcopenshell.api.run("profile.add_arbitrary_profile", tool.Ifc.get(), profile=points) else: profile = ifcopenshell.api.run("profile.add_parameterized_profile", tool.Ifc.get(), ifc_class=profile_class) + tool.Profile.set_default_profile_attrs(profile) profile.ProfileName = "New Profile" bpy.ops.bim.load_profiles() @@ -153,12 +177,12 @@ class DuplicateProfileDef(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.load_profiles() -class EnableEditingArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingArbitraryProfile(bpy.types.Operator): bl_idname = "bim.enable_editing_arbitrary_profile" bl_label = "Enable Editing Arbitrary Profile" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): props = context.scene.BIMProfileProperties active_profile = props.profiles[props.active_profile_index] profile_id = active_profile.ifc_definition_id @@ -171,6 +195,7 @@ class EnableEditingArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_arbitrary_profile(context)) tool.Blender.set_viewport_tool("bim.cad_tool") + return {"FINISHED"} def disable_editing_arbitrary_profile(context): @@ -189,13 +214,14 @@ def disable_editing_arbitrary_profile(context): refresh() -class DisableEditingArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingArbitraryProfile(bpy.types.Operator): bl_idname = "bim.disable_editing_arbitrary_profile" bl_label = "Disable Editing Arbitrary Profile" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): - return disable_editing_arbitrary_profile(context) + def execute(self, context): + disable_editing_arbitrary_profile(context) + return {"FINISHED"} class EditArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): @@ -241,18 +267,3 @@ class EditArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): props.active_arbitrary_profile_id = 0 model_profile.DumbProfileRegenerator().regenerate_from_profile_def(profile) - - -class PurgeUnusedProfiles(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.purge_unused_profiles" - bl_label = "Purge Unused Profiles" - bl_options = {"REGISTER", "UNDO"} - - def _execute(self, context): - props = context.scene.BIMProfileProperties - purged_profiles = core.purge_unused_profiles(tool.Ifc, tool.Profile) - self.report({"INFO"}, f"{purged_profiles} profiles were purged.") - - if props.is_editing: - refresh() - bpy.ops.bim.load_profiles() diff --git a/src/bonsai/bonsai/bim/module/profile/prop.py b/src/bonsai/bonsai/bim/module/profile/prop.py index afe21b1446..9fa4f5e360 100644 --- a/src/bonsai/bonsai/bim/module/profile/prop.py +++ b/src/bonsai/bonsai/bim/module/profile/prop.py @@ -81,14 +81,24 @@ def generate_thumbnail_for_active_profile(): if not props.profiles: bpy.ops.bim.load_profiles() - profile_id = props.profiles[props.active_profile_index].ifc_definition_id + active_profile = tool.Profile.get_active_profile_ui() + assert active_profile + profile_id = active_profile.ifc_definition_id profile = ifc_file.by_id(profile_id) # generate image size = 128 img = Image.new("RGBA", (size, size)) draw = ImageDraw.Draw(img) - tool.Profile.draw_image_for_ifc_profile(draw, profile, size) + + try: + tool.Profile.draw_image_for_ifc_profile(draw, profile, size) + except RuntimeError as e: + print(f"Failed to generate preview image for profile '{profile}': '{e}'.") + ProfileData.failed_previews.add(profile_id) + return + + ProfileData.failed_previews.discard(profile_id) pixels = [item for sublist in img.getdata() for item in sublist] # save generated image to preview collection diff --git a/src/bonsai/bonsai/bim/module/profile/ui.py b/src/bonsai/bonsai/bim/module/profile/ui.py index 56c5aefe96..23eecc0037 100644 --- a/src/bonsai/bonsai/bim/module/profile/ui.py +++ b/src/bonsai/bonsai/bim/module/profile/ui.py @@ -43,19 +43,22 @@ class BIM_PT_profiles(Panel): self.props = context.scene.BIMProfileProperties active_profile = None - if self.props.is_editing and self.props.profiles and self.props.active_profile_index < len(self.props.profiles): + if self.props.is_editing and (active_profile := tool.Profile.get_active_profile_ui()): preview_collection = ProfileData.preview_collection box = self.layout.box() - active_profile = self.props.profiles[self.props.active_profile_index] profile_id = active_profile.ifc_definition_id - profile_id_str = str(profile_id) - if profile_id_str in preview_collection: - preview_image = preview_collection[profile_id_str] - else: - preview_image = preview_collection.new(profile_id_str) - generate_thumbnail_for_active_profile() - box.template_icon(icon_value=preview_image.icon_id, scale=5) + if profile_id in ProfileData.failed_previews: + box.label(text="Failed to load preview (invalid profile).", icon="ERROR") + else: + profile_id_str = str(profile_id) + if profile_id_str in preview_collection: + preview_image = preview_collection[profile_id_str] + else: + preview_image = preview_collection.new(profile_id_str) + generate_thumbnail_for_active_profile() + + box.template_icon(icon_value=preview_image.icon_id, scale=5) row = self.layout.row(align=True) row.label(text=f"{ProfileData.data['total_profiles']} Named Profiles", icon="ITALIC") diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index f04c4c98db..aae95903ad 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -53,6 +53,7 @@ from ifcopenshell.geom import ShapeElementType from bonsai.bim.module.project.data import LinksData from bonsai.bim.module.project.decorator import ProjectDecorator, ClippingPlaneDecorator from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.module.model.polyline import PolylineOperator from typing import Union @@ -130,6 +131,7 @@ class CreateProject(bpy.types.Operator): ) bonsai.bim.schema.reload(tool.Ifc.get().schema_identifier) tool.Blender.register_toolbar() + tool.Root.reload_item_decorator() def rollback(self, data): IfcStore.file = None @@ -200,10 +202,8 @@ class RefreshLibrary(bpy.types.Operator): self.props.active_library_element = "" - types = IfcStore.library_file.wrapped_data.types_with_super() - for importable_type in sorted(tool.Project.get_appendable_asset_types()): - if importable_type in types: + if IfcStore.library_file.by_type(importable_type): new = self.props.library_elements.add() new.name = importable_type return {"FINISHED"} @@ -369,7 +369,7 @@ class SaveLibraryFile(bpy.types.Operator): return {"FINISHED"} -class AppendEntireLibrary(bpy.types.Operator): +class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.append_entire_library" bl_label = "Append Entire Library" @@ -377,9 +377,6 @@ class AppendEntireLibrary(bpy.types.Operator): def poll(cls, context): return IfcStore.get_file() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = IfcStore.get_file() self.library = IfcStore.library_file @@ -391,7 +388,7 @@ class AppendEntireLibrary(bpy.types.Operator): return {"FINISHED"} -class AppendLibraryElementByQuery(bpy.types.Operator): +class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.append_library_element_by_query" bl_label = "Append Library Element By Query" query: bpy.props.StringProperty(name="Query") @@ -400,9 +397,6 @@ class AppendLibraryElementByQuery(bpy.types.Operator): def poll(cls, context): return IfcStore.get_file() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = IfcStore.get_file() self.library = IfcStore.library_file @@ -418,7 +412,7 @@ class AppendLibraryElementByQuery(bpy.types.Operator): self.layout.prop(self, "query") -class AppendLibraryElement(bpy.types.Operator): +class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.append_library_element" bl_label = "Append Library Element" bl_options = {"REGISTER", "UNDO"} @@ -433,9 +427,6 @@ class AppendLibraryElement(bpy.types.Operator): cls.poll_message_set("Please create or load a project first.") return poll - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = IfcStore.get_file() element = ifcopenshell.api.run( @@ -504,19 +495,8 @@ class AppendLibraryElement(bpy.types.Operator): logger = logging.getLogger("ImportIFC") ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger) - type_collection = bpy.data.collections.get("Types") - if not type_collection: - type_collection = bpy.data.collections.new("Types") - for collection in bpy.context.view_layer.layer_collection.children: - collection_obj = collection.collection.BIMCollectionProperties.obj - if collection_obj and tool.Ifc.get_entity(collection_obj).is_a("IfcProject"): - collection.collection.children.link(type_collection) - collection.children["Types"].hide_viewport = True - break - ifc_importer = import_ifc.IfcImporter(ifc_import_settings) ifc_importer.file = self.file - ifc_importer.type_collection = type_collection ifc_importer.process_context_filter() ifc_importer.material_creator.load_existing_materials() self.import_materials(element, ifc_importer) @@ -666,9 +646,25 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): bl_description = "Load an existing IFC project" filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}) - is_advanced: bpy.props.BoolProperty(name="Enable Advanced Mode", default=False) + is_advanced: bpy.props.BoolProperty( + name="Enable Advanced Mode", + description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings", + default=False, + ) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) - should_start_fresh_session: bpy.props.BoolProperty(name="Should Start Fresh Session", default=True) + should_start_fresh_session: bpy.props.BoolProperty( + name="Should Start Fresh Session", + description="Clear current Blender session before loading IFC", + default=True, + ) + import_without_ifc_data: bpy.props.BoolProperty( + name="Import Without IFC Data", + description=( + "Import IFC objects as Blender objects without any IFC metadata and authoring capabilities." + "Can be useful for work with purely IFC geometry" + ), + default=False, + ) use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) @classmethod @@ -742,6 +738,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): for obj in bpy.data.objects: bpy.data.objects.remove(obj) + # To be safe from any accidental IFC data in the previous session. + if not self.is_advanced and not self.should_start_fresh_session: + bpy.ops.bim.convert_to_blender() + filepath = Path(self.get_filepath()) context.scene.BIMProperties.ifc_file = filepath.as_posix() context.scene.BIMProjectProperties.is_loading = True @@ -751,7 +751,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): if not self.is_advanced: bpy.ops.bim.load_project_elements() - if not self.should_start_fresh_session: + if self.import_without_ifc_data: bpy.ops.bim.convert_to_blender() except: bonsai.last_error = traceback.format_exc() @@ -766,6 +766,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): def draw(self, context): self.layout.prop(self, "is_advanced") + self.layout.prop(self, "should_start_fresh_session") + self.layout.prop(self, "import_without_ifc_data") IFCFileSelector.draw(self, context) @@ -840,6 +842,8 @@ class LoadProjectElements(bpy.types.Operator): tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() + tool.Root.reload_grid_decorator() + tool.Root.reload_item_decorator() return {"FINISHED"} def get_decomposition_elements(self): @@ -1317,6 +1321,8 @@ class ExportIFCBase: ifc_exporter.export() settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start)) 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() @@ -1714,7 +1720,7 @@ class LoadLinkedProject(bpy.types.Operator): mesh.polygons.foreach_set("loop_total", loop_total) mesh.polygons.foreach_set("use_smooth", [0] * total_faces) - if material_ids.size > 0: + if material_ids.size > 0 and len(mesh.polygons) == len(material_ids): mesh.polygons.foreach_set("material_index", material_ids) mesh.update() @@ -2289,229 +2295,64 @@ if bpy.app.version >= (4, 1, 0): return True -class MeasureTool(bpy.types.Operator): +class MeasureTool(bpy.types.Operator, PolylineOperator): bl_idname = "bim.measure_tool" - bl_options = {"REGISTER", "UNDO"} bl_label = "Measure Tool" + bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.space_data.type == "VIEW_3D" def __init__(self): - self.mousemove_count = 0 - self.action_count = 0 - self.visible_objs = [] - self.objs_2d_bbox = [] - self.number_options = { - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - " ", - ".", - "+", - "-", - "*", - "/", - "'", - '"', - "=", - } - self.number_input = [] - self.number_output = "" - self.number_is_negative = False - self.is_input_on = False + super().__init__() + self.input_ui = tool.Polyline.create_input_ui(init_z=True) self.input_options = ["D", "A", "X", "Y", "Z"] - self.input_type = "OFF" - self.input_value_xy = [None, None] - self.input_panel = {"D": "", "A": "", "X": "", "Y": "", "Z": ""} - self.snap_angle = None - self.snapping_points = [] - - def recalculate_inputs(self, context): - if self.number_input: - is_valid, self.number_output = tool.Snap.validate_input(self.number_output, self.input_type) - self.input_panel[self.input_type] = self.number_output - if not is_valid: - self.report({"WARNING"}, "The number typed is not valid.") - return is_valid - else: - if self.input_type in {"X", "Y", "Z"}: - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - elif self.input_type in {"D", "A"}: - self.input_panel = PolylineDecorator.calculate_x_y_and_z(context) - - self.input_panel[self.input_type] = self.number_output - - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - return is_valid + self.instructions = """TAB: Cycle Input + D: Distance Input + A: Angle Input + M: Modify Snap Point + C: Close Polyline + BACKSPACE: Remove Point + X, Y, Z: Choose Axis + S-X, S-Y, S-Z: Choose Plane + SHIFT: Lock axis + """ def modal(self, context, event): + super().modal(context, event) - if not self.is_input_on: - if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": - self.mousemove_count += 1 - self.is_input_on = False - self.input_type = "OFF" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Snap.clear_snapping_ref() - tool.Blender.update_viewport() - else: - self.mousemove_count = 0 + self.handle_instructions(context) - if self.mousemove_count == 2: - self.objs_2d_bbox = [] - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_objects_2d_bounding_boxes(context, obj)) + self.handle_mouse_move(context, event) - if self.mousemove_count > 3: - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - return {"RUNNING_MODAL"} + self.choose_axis(event, z=True) - if event.value == "RELEASE" and event.type == "BACK_SPACE": - tool.Snap.remove_last_polyline_point() - tool.Blender.update_viewport() + self.choose_plane(event) - if event.value == "RELEASE" and event.type == "LEFTMOUSE": - tool.Snap.insert_polyline_point(self.input_panel) + self.handle_snap_selection(context, event) + + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + tool.Snap.clear_polyline() tool.Blender.update_viewport() + return {"FINISHED"} - if event.value == "PRESS" and event.type == "C": - tool.Snap.close_polyline() - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() + self.handle_keyboard_input(context, event) - if self.is_input_on and event.value == "PRESS" and event.type == "TAB": - self.recalculate_inputs(context) - index = self.input_options.index(self.input_type) - size = len(self.input_options) - self.input_type = self.input_options[((index + 1) % size)] - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() + self.handle_inserting_polyline(context, event) - if not self.is_input_on and event.value == "RELEASE" and event.type == "TAB": - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.ascii in self.number_options: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type in self.input_options and not event.shift: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = event.type - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.input_type in self.input_options: - if (event.ascii in self.number_options) or (event.value == "RELEASE" and event.type == "BACK_SPACE"): - if event.type == "BACK_SPACE": - if len(self.number_input) <= 1: - self.number_input = [] - else: - self.number_input = self.number_input[:-1] - self.number_output = "".join(self.number_input) - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - self.number_input.append(event.ascii) - self.number_output = "".join(self.number_input) - - if self.number_input: - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: - is_valid = self.recalculate_inputs(context) - if is_valid: - tool.Snap.insert_polyline_point(self.input_panel) - self.is_input_on = False - self.input_type = "OFF" - self.number_input = [] - self.number_output = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "M": - self.snapping_points = tool.Snap.modify_snapping_point_selection(self.snapping_points) - tool.Blender.update_viewport() - - if event.shift and event.value == "PRESS" and event.type == "X": - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.set_snap_plane_method("YZ") - - if event.shift and event.value == "PRESS" and event.type == "Y": - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.set_snap_plane_method("XZ") - - if event.shift and event.value == "PRESS" and event.type == "Z": - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.set_snap_plane_method("XY") - - if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: - return {"PASS_THROUGH"} - - if self.is_input_on: - if event.value == "RELEASE" and event.type in {"ESC"}: - self.is_input_on = False - self.input_type = "OFF" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - if event.value == "RELEASE" and event.type in {"ESC"}: - PolylineDecorator.uninstall() - tool.Snap.clear_polyline() - tool.Blender.update_viewport() - return {"CANCELLED"} + result = self.handle_cancelation(context, event) + if result is not None: + return result return {"RUNNING_MODAL"} def invoke(self, context, event): - if context.space_data.type == "VIEW_3D": - PolylineDecorator.install(context) - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.set_snap_plane_method("No Plane") - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - self.visible_objs = tool.Raycast.get_visible_objects(context) - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_objects_2d_bounding_boxes(context, obj)) - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - context.window_manager.modal_handler_add(self) - return {"RUNNING_MODAL"} - else: - self.report({"WARNING"}, "Active space must be a View3d") - return {"CANCELLED"} + super().invoke(context, event) + return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 340ffcb85f..5bbad8d5fb 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -143,6 +143,15 @@ class BIMProjectProperties(PropertyGroup): active_filter_category_index: IntProperty(name="Active Filter Category Index") filter_query: StringProperty(name="Filter Query") should_filter_spatial_elements: BoolProperty(name="Filter Spatial Elements", default=False) + geometry_library: bpy.props.EnumProperty( + items=[ + ("opencascade", "OpenCASCADE", "Best for stability and accuracy"), + ("cgal", "CGAL", "Best for speed"), + ("cgal-simple", "CGAL Simple", "CGAL without booleans"), + ("hybrid-cgal-simple-opencascade", "Hybrid CGAL-OCC", "First CGAL then fallback to OCC"), + ], + name="Geometry Library", + ) should_use_cpu_multiprocessing: BoolProperty(name="CPU Multiprocessing", default=True) 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) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 05f8bdbd27..4d0c30330a 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -27,6 +27,7 @@ from bonsai.bim.module.project.data import ProjectData, LinksData def file_import_menu(self, context): op = self.layout.operator("bim.load_project", text="IFC (Geometry Only) (.ifc/.ifczip/.ifcxml)") + op.import_without_ifc_data = True op.should_start_fresh_session = False @@ -132,7 +133,7 @@ class BIM_PT_project(Panel): row = box.row(align=True) row.label(text="Your Model May Be Outdated", icon="ERROR") op = row.operator("bim.open_uri", text="", icon="QUESTION") - op.uri = "https://docs.bonsaibim.org/users/troubleshooting.html#saving-and-loading-blend-files" + op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files" row.operator("bim.close_blend_warning", text="", icon="CANCEL") if props.ifc_file: @@ -175,6 +176,8 @@ class BIM_PT_project(Panel): row = self.layout.row() row.prop(pprops, "should_merge_materials_by_colour") row = self.layout.row() + row.prop(pprops, "geometry_library") + row = self.layout.row() row.prop(pprops, "deflection_tolerance") row = self.layout.row() row.prop(pprops, "angular_tolerance") @@ -517,5 +520,7 @@ class BIM_PT_purge(Panel): def draw(self, context): layout = self.layout - layout.operator("bim.purge_unused_profiles") - layout.operator("bim.purge_unused_types") + layout.operator("bim.purge_unused_objects", text="Purge Unused Profiles").object_type = "PROFILE" + layout.operator("bim.purge_unused_objects", text="Purge Unused Types").object_type = "TYPE" + layout.operator("bim.purge_unused_objects", text="Purge Unused Materials").object_type = "MATERIAL" + layout.operator("bim.purge_unused_objects", text="Purge Unused Styles").object_type = "STYLE" diff --git a/src/bonsai/bonsai/bim/module/pset/__init__.py b/src/bonsai/bonsai/bim/module/pset/__init__.py index 1bea76dc93..14706e75be 100644 --- a/src/bonsai/bonsai/bim/module/pset/__init__.py +++ b/src/bonsai/bonsai/bim/module/pset/__init__.py @@ -29,6 +29,7 @@ classes = ( operator.EnablePsetEditing, operator.RemovePset, operator.TogglePsetExpansion, + operator.UnsharePset, operator.BIM_OT_add_property_to_edit, operator.BIM_OT_remove_property_to_edit, operator.BIM_OT_clear_list, diff --git a/src/bonsai/bonsai/bim/module/pset/data.py b/src/bonsai/bonsai/bim/module/pset/data.py index c0a19f7bf0..e9768ff0ab 100644 --- a/src/bonsai/bonsai/bim/module/pset/data.py +++ b/src/bonsai/bonsai/bim/module/pset/data.py @@ -46,18 +46,22 @@ def refresh(): class Data: @classmethod - def psetqtos(cls, element, psets_only=False, qtos_only=False): + def psetqtos(cls, element: ifcopenshell.entity_instance, psets_only: bool = False, qtos_only: bool = False): + ifc_file = tool.Ifc.get() results = [] psetqtos = ifcopenshell.util.element.get_psets( element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False ) for name, data in sorted(psetqtos.items()): + pset = ifc_file.by_id(data["id"]) + pset_uses = ifcopenshell.util.element.get_elements_by_pset(pset) results.append( { "id": data["id"], "Name": name, "is_expanded": is_expanded.get(data["id"], True), "Properties": [{"Name": k, "NominalValue": v} for k, v in sorted(data.items()) if k != "id"], + "shared_pset_uses": len(pset_uses), } ) return sorted(results, key=lambda v: v["Name"]) @@ -286,9 +290,18 @@ class ProfilePsetsData(Data): @classmethod def load(cls): - pprops = bpy.context.scene.BIMProfileProperties - ifc_definition_id = pprops.profiles[pprops.active_profile_index].ifc_definition_id - cls.data = {"psets": cls.psetqtos(tool.Ifc.get().by_id(ifc_definition_id), psets_only=True)} + active_profile = tool.Profile.get_active_profile_ui() + if active_profile: + ifc_definition_id = active_profile.ifc_definition_id + psets_data = cls.psetqtos(tool.Ifc.get().by_id(ifc_definition_id), psets_only=True) + else: + ifc_definition_id = 0 + psets_data = [] + + cls.data = { + "ifc_definition_id": ifc_definition_id, + "psets": psets_data, + } cls.is_loaded = True diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index e417de2063..fe08585701 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -19,6 +19,7 @@ import bpy import json import ifcopenshell.api +import ifcopenshell.api.pset import ifcopenshell.util.attribute import ifcopenshell.util.element import ifcopenshell.util.pset @@ -190,6 +191,31 @@ class AddPset(bpy.types.Operator, tool.Ifc.Operator): core.add_pset(tool.Ifc, tool.Pset, tool.Blender, obj_name=self.obj, obj_type=self.obj_type) +class UnsharePset(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.unshare_pset" + bl_label = "Unshare Pset" + bl_description = ( + "Click to copy a pset as linked only to the selected objects. " + "If multiple objects are selected, each will get a separate pset copy.\n\n" + "Otherwise changing a pset shared by multiple elements " + "will change it's properties for all the elements it's linked to, not just for the active object" + ) + bl_options = {"REGISTER", "UNDO"} + description_: bpy.props.StringProperty(name="Custom Tooltip Description") + pset_id: bpy.props.IntProperty() + obj: bpy.props.StringProperty() + obj_type: bpy.props.StringProperty() + + @classmethod + def description(cls, context, properties): + if not properties.description_: + return cls.bl_description + return f"{properties.description_}{cls.bl_description}" + + def _execute(self, context): + core.unshare_pset(tool.Ifc, tool.Pset, self.obj_type, self.obj, self.pset_id) + + class AddQto(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_qto" bl_label = "Add Qto" @@ -279,15 +305,12 @@ class BIM_OT_clear_list(bpy.types.Operator): return {"FINISHED"} -class BIM_OT_rename_parameters(bpy.types.Operator): +class BIM_OT_rename_parameters(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Rename Parameters" bl_idname = "bim.rename_parameters" bl_options = {"REGISTER", "UNDO"} bl_description = "Rename parameters that are subclasses of IfcElement" - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): props_to_map = context.scene.RenameProperties ifc_file = IfcStore.get_file() @@ -316,15 +339,12 @@ class BIM_OT_rename_parameters(bpy.types.Operator): obj_prop.Name = prop2map.new_property_name -class BIM_OT_add_edit_custom_property(bpy.types.Operator): +class BIM_OT_add_edit_custom_property(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Add or Edit a Custom Property" bl_idname = "bim.add_edit_custom_property" bl_options = {"REGISTER", "UNDO"} index: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = IfcStore.get_file() props = context.scene.AddEditProperties @@ -373,16 +393,13 @@ class BIM_OT_add_edit_custom_property(bpy.types.Operator): return prop_enum_value -class BIM_OT_bulk_remove_psets(bpy.types.Operator): +class BIM_OT_bulk_remove_psets(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Bulk Remove Psets from Selected Objects" bl_idname = "bim.bulk_remove_psets" bl_options = {"REGISTER", "UNDO"} bl_description = "Bulk remove psets from selected objects" index: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): self.file = IfcStore.get_file() props = context.scene.DeletePsets @@ -416,6 +433,14 @@ class BIM_OT_bulk_remove_psets(bpy.types.Operator): class AddProposedProp(bpy.types.Operator): bl_idname = "bim.add_proposed_prop" bl_label = "Add Proposed Prop" + bl_description = ( + "Add proposed property to the custom property set.\n\n" + "Property type will be deduced from the provided value. Possible types:\n" + "- provide an integer or a float to create integer/real property\n" + "- 'true', 'false' to add a boolean property\n" + "- 'null' or '' (empty value) to add a null property\n" + "- any other value will be added as a string property" + ) bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() obj_type: bpy.props.StringProperty() @@ -423,5 +448,8 @@ class AddProposedProp(bpy.types.Operator): prop_value: bpy.props.StringProperty() def execute(self, context): - core.add_proposed_prop(tool.Pset, self.obj, self.obj_type, self.prop_name, self.prop_value) + res = core.add_proposed_prop(tool.Pset, self.obj, self.obj_type, self.prop_name, self.prop_value) + if res: + self.report({"ERROR"}, res) + return {"CANCELLED"} return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/pset/prop.py b/src/bonsai/bonsai/bim/module/pset/prop.py index 074ac3d73f..7804521221 100644 --- a/src/bonsai/bonsai/bim/module/pset/prop.py +++ b/src/bonsai/bonsai/bim/module/pset/prop.py @@ -243,6 +243,7 @@ class PsetProperties(PropertyGroup): properties: CollectionProperty(name="Properties", type=IfcProperty) pset_name: EnumProperty(items=get_pset_name, name="Pset Name") qto_name: EnumProperty(items=get_qto_name, name="Qto Name") + # Proposed property. prop_name: StringProperty(name="Property Name", default="MyProperty") prop_value: StringProperty(name="Property Value", default="Some Value") diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index 021f8563af..1625bc8966 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -120,7 +120,17 @@ def draw_psetqto_ui( op.obj = obj_name op.obj_type = obj_type elif not props.active_pset_id: - row.label(text=pset["Name"], icon="COPY_ID") + row.label(text=f'{pset["Name"]}', icon="COPY_ID") + + if (shared := pset["shared_pset_uses"]) > 1: + unshare_pset_row = row.row(align=True) + unshare_pset_row.alignment = "RIGHT" + op = unshare_pset_row.operator("bim.unshare_pset", text=str(shared)) + op.description_ = f"Pset is reused by {shared} elements.\n\n" + op.pset_id = pset_id + op.obj = obj_name + op.obj_type = obj_type + op = row.operator("bim.enable_pset_editing", icon="GREASEPENCIL", text="") op.pset_id = pset_id op.obj = obj_name @@ -141,10 +151,17 @@ def draw_psetqto_ui( remove_pset_row.enabled = allow_removing if pset["is_expanded"]: if props.active_pset_id == pset_id: + is_parametric_pset = props.active_pset_name in tool.Model.BBIM_PARAMETRIC_PSETS + if is_parametric_pset: + box_ = box.box() + box_.alert = True + box_.label(text="Warning! This pset should not be edited directly", icon="ERROR") + box_.label(text="and should be edited from Parametric Geometry UI.") + for prop in props.properties: draw_psetqto_editable_ui(box, props, prop) - if not props.active_pset_has_template: + if not props.active_pset_has_template and not is_parametric_pset: row = box.row(align=True) row.prop(props, "prop_name", text="") row.prop(props, "prop_value", text="") @@ -654,7 +671,15 @@ class BIM_PT_profile_psets(Panel): return False def draw(self, context): - if not ProfilePsetsData.is_loaded: + active_profile = tool.Profile.get_active_profile_ui() + + if not active_profile: + return + + if ( + not ProfilePsetsData.is_loaded + or active_profile.ifc_definition_id != ProfilePsetsData.data["ifc_definition_id"] + ): ProfilePsetsData.load() props = context.scene.ProfilePsetProperties diff --git a/src/bonsai/bonsai/bim/module/root/__init__.py b/src/bonsai/bonsai/bim/module/root/__init__.py index cf8f4b44ad..f2da6d3355 100644 --- a/src/bonsai/bonsai/bim/module/root/__init__.py +++ b/src/bonsai/bonsai/bim/module/root/__init__.py @@ -20,10 +20,12 @@ import bpy from . import ui, prop, operator classes = ( + operator.AddElement, operator.AssignClass, operator.CopyClass, 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 3a11031e83..38db05960e 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -42,6 +42,7 @@ class IfcClassData: cls.data["ifc_products"] = cls.ifc_products() cls.data["ifc_classes"] = cls.ifc_classes() cls.data["ifc_classes_suggestions"] = cls.ifc_classes_suggestions() # Call AFTER cls.ifc_classes() + cls.data["representation_template"] = cls.representation_template() cls.data["contexts"] = cls.contexts() cls.data["has_entity"] = cls.has_entity() @@ -58,9 +59,7 @@ class IfcClassData: "IfcElement", "IfcSpatialElement", "IfcSpatialElementType", - "IfcGroup", "IfcStructuralItem", - "IfcContext", "IfcAnnotation", "IfcRelSpaceBoundary", ] @@ -70,7 +69,6 @@ class IfcClassData: "IfcElementType", "IfcElement", "IfcSpatialStructureElement", - "IfcGroup", "IfcStructuralItem", "IfcAnnotation", "IfcRelSpaceBoundary", @@ -130,6 +128,87 @@ class IfcClassData: suggestions[ifc_class].append(suggestion_dict) return suggestions + @classmethod + def representation_template(cls): + ifc_class = bpy.context.scene.BIMRootProperties.ifc_class + templates = [ + ("EMPTY", "No Geometry", "Start with an empty object"), + None, + ] + templates.extend( + [ + ( + "OBJ", + "Tessellation From Object", + "Use an object as a template to create a new tessellation", + ), + ( + "MESH", + "Custom Tessellation", + "Create a basic tessellated or faceted cube", + ), + ( + "EXTRUSION", + "Custom Extruded Solid", + "An extrusion from an arbitrary profile", + ), + ] + ) + if ifc_class.endswith("Type") or ifc_class.endswith("Style"): + templates.extend( + [ + None, + ( + "LAYERSET_AXIS2", + "Vertical Layers", + "For objects similar to walls, will automatically add IfcMaterialLayerSet", + ), + ( + "LAYERSET_AXIS3", + "Horizontal Layers", + "For objects similar to slabs, will automatically add IfcMaterialLayerSet", + ), + ( + "PROFILESET", + "Extruded Profile", + "Create profile type object, automatically defines IfcMaterialProfileSet with the first profile from library", + ), + ] + ) + if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"): + templates.extend([None, ("WINDOW", "Window", "Parametric window")]) + elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"): + templates.extend([None, ("DOOR", "Door", "Parametric door")]) + elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"): + 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"): + templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")]) + elif ifc_class and "Segment" in ifc_class: + templates.extend( + ( + None, + ( + "FLOW_SEGMENT_RECTANGULAR", + "Rectangular Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), + ( + "FLOW_SEGMENT_CIRCULAR", + "Circular Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), + ( + "FLOW_SEGMENT_CIRCULAR_HOLLOW", + "Circular Hollow Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), + ) + ) + + return templates + @classmethod def contexts(cls): results = [] diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index ca84543dea..acb272eae8 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bmesh import ifcopenshell import ifcopenshell.api import ifcopenshell.util.schema @@ -24,9 +25,12 @@ import ifcopenshell.util.element import ifcopenshell.util.type import bonsai.bim.handler import bonsai.core.root as core +import bonsai.core.geometry import bonsai.tool as tool +import bonsai.bim.module.root.prop as root_prop from bonsai.bim.ifc import IfcStore -from bonsai.bim.helper import get_enum_items +from bonsai.bim.helper import get_enum_items, prop_with_search +from mathutils import Vector class EnableReassignClass(bpy.types.Operator): @@ -74,86 +78,84 @@ class DisableReassignClass(bpy.types.Operator): return {"FINISHED"} -class ReassignClass(bpy.types.Operator): +class ReassignClass(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.reassign_class" bl_label = "Reassign IFC Class" + bl_description = "Reassign IFC class for selected objects" bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): if self.obj: objects = [bpy.data.objects.get(self.obj)] else: objects = set(context.selected_objects + [context.active_object]) self.file = IfcStore.get_file() - predefined_type = context.scene.BIMRootProperties.ifc_predefined_type + root_props = context.scene.BIMRootProperties + ifc_product: str = root_props.ifc_product + ifc_class: str = root_props.ifc_class + type_ifc_class = next(iter(ifcopenshell.util.type.get_applicable_types(ifc_class)), None) + + predefined_type = root_props.ifc_predefined_type if predefined_type == "USERDEFINED": - predefined_type = context.scene.BIMRootProperties.ifc_userdefined_type + predefined_type = root_props.ifc_userdefined_type # NOTE: root.reassign_class # automatically will reassign class for other occurrences of the type # so we need to run it only for the types or non-typed elements - elements_to_reassign = set() + elements_to_reassign: dict[ifcopenshell.entity_instance, str] = dict() # need to update blender object name # for all elements that were changed in the process - elements_to_update = set() + elements_to_update: set[ifcopenshell.entity_instance] = set() for obj in objects: - obj.BIMObjectProperties.is_reassigning_class = False element = tool.Ifc.get_entity(obj) - if element.is_a("IfcTypeObject"): - elements_to_reassign.add(element) - elements_to_update.update(ifcopenshell.util.element.get_types(element)) + if not element: continue - # check if element is typed - element_type = ifcopenshell.util.element.get_type(element) - if element_type: - elements_to_reassign.add(element_type) + same_ifc_product = element.is_a(ifc_product) + + if not same_ifc_product: + if not (element.is_a("IfcElement") and ifc_product == "IfcElementType") and not ( + element.is_a("IfcElementType") and ifc_product == "IfcElement" + ): + self.report( + {"ERROR"}, f"Not supported class reassignment for object '{obj.name}' -> {ifc_product}." + ) + return {"CANCELLED"} + + obj.BIMObjectProperties.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)) + continue + elif same_ifc_product and (element_type := ifcopenshell.util.element.get_type(element)): + assert type_ifc_class + elements_to_reassign[element_type] = type_ifc_class elements_to_update.update(ifcopenshell.util.element.get_types(element_type)) continue # non-typed element - elements_to_reassign.add(element) + elements_to_reassign[element] = ifc_class # store elements to objects to update later as elements will get invalid # after class reassignment - elements_to_update = elements_to_update | elements_to_reassign + 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))) - base_class = context.scene.BIMRootProperties.ifc_class - if context.scene.BIMRootProperties.ifc_product == "IfcElementType": - type_class = base_class - occurrence_classes = ifcopenshell.util.type.get_applicable_entities(type_class) - occurrence_class = None if len(occurrence_classes) == 0 else occurrence_classes[0] - else: - occurrence_class = base_class - type_classes = ifcopenshell.util.type.get_applicable_types(occurrence_class) - type_class = None if len(type_classes) == 0 else type_classes[0] - reassigned_elements = set() - for element in elements_to_reassign: - ifc_class = type_class if element.is_a("IfcTypeObject") else occurrence_class - if ifc_class is None: - self.report( - {"ERROR"}, - f"Couldn't find valid class for reassigning element of class {element.is_a()} based on class {base_class}", - ) - return {"CANCELLED"} - + for element, ifc_class_ in elements_to_reassign.items(): element = ifcopenshell.api.run( "root.reassign_class", self.file, product=element, - ifc_class=ifc_class, + ifc_class=ifc_class_, predefined_type=predefined_type, ) reassigned_elements.add(element) for obj in objects_to_update: obj.name = tool.Loader.get_name(tool.Ifc.get_entity(obj)) + tool.Collector.assign(obj) return {"FINISHED"} @@ -196,21 +198,39 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): if obj.mode != "OBJECT": self.report({"ERROR"}, "Object must be in OBJECT mode to assign class") continue - core.assign_class( + element = core.assign_class( tool.Ifc, tool.Collector, tool.Root, obj=obj, ifc_class=ifc_class, predefined_type=predefined_type, - should_add_representation=self.should_add_representation, + should_add_representation=False, context=ifc_context, ifc_representation_class=self.ifc_representation_class, ) + if self.should_add_representation and obj.data and len(obj.data.vertices): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + verts = [v.co / unit_scale for v in obj.data.vertices] + faces = [p.vertices[:] for p in obj.data.polygons] + item = builder.mesh(verts, faces) + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + context.view_layer.objects.active = active_object -class UnlinkObject(bpy.types.Operator): +class UnlinkObject(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unlink_object" bl_label = "Unlink Object" bl_description = ( @@ -224,9 +244,6 @@ class UnlinkObject(bpy.types.Operator): should_delete: bpy.props.BoolProperty(name="Delete IFC Element", default=True) skip_invoke: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): if self.obj: objects = [bpy.data.objects.get(self.obj)] @@ -307,3 +324,240 @@ class CopyClass(bpy.types.Operator, tool.Ifc.Operator): for obj in objects: core.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=obj) bonsai.bim.handler.refresh_ui_data() + + +class AddElement(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_element" + bl_label = "Add Element" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Add an IFC physical product, construction type, and more" + + def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, is_invoke=True) + + def _invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + props = context.scene.BIMRootProperties + predefined_type = ( + props.userdefined_type if props.ifc_predefined_type == "USERDEFINED" else props.ifc_predefined_type + ) + representation_template = props.representation_template + + ifc_context = None + if get_enum_items(props, "contexts", context): + ifc_context = int(props.contexts or "0") or None + if ifc_context: + ifc_context = tool.Ifc.get().by_id(ifc_context) + + if representation_template in ( + "EMPTY", + "LAYERSET_AXIS2", + "LAYERSET_AXIS3", + "PROFILESET", + ) or representation_template.startswith("FLOW_SEGMENT_"): + mesh = None + elif representation_template == "OBJ" and not props.representation_obj: + mesh = None + else: + mesh = bpy.data.meshes.new("Mesh") + + obj = bpy.data.objects.new(props.ifc_class[3:], mesh) + obj.location = bpy.context.scene.cursor.location + element = core.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class=props.ifc_class, + predefined_type=predefined_type, + should_add_representation=False, + ) + + if representation_template == "EMTPY" or not ifc_context: + pass + elif representation_template == "OBJ" and not props.representation_obj: + pass + elif representation_template == "OBJ": + obj.matrix_world = props.representation_obj.matrix_world + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + verts = [v.co / unit_scale for v in props.representation_obj.data.vertices] + faces = [p.vertices[:] for p in props.representation_obj.data.polygons] + item = builder.mesh(verts, faces) + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + if not tool.Ifc.get_entity(props.representation_obj): + bpy.data.objects.remove(props.representation_obj) + elif representation_template == "MESH": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=0.5) + verts = [v.co / 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) + bm.free() + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + elif representation_template == "EXTRUSION": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale) + item = builder.extrude(curve, magnitude=0.5 / unit_scale) + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + elif representation_template in ("LAYERSET_AXIS2", "LAYERSET_AXIS3"): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + materials = tool.Ifc.get().by_type("IfcMaterial") + if materials: + material = materials[0] # Arbitrarily pick a material + else: + material = ifcopenshell.api.run("material.add_material", tool.Ifc.get(), name="Unknown") + rel = ifcopenshell.api.run( + "material.assign_material", tool.Ifc.get(), products=[element], type="IfcMaterialLayerSet" + ) + layer_set = rel.RelatingMaterial + layer = ifcopenshell.api.run("material.add_layer", tool.Ifc.get(), layer_set=layer_set, material=material) + thickness = 0.1 # Arbitrary metric thickness for now + layer.LayerThickness = thickness / unit_scale + pset = ifcopenshell.api.run("pset.add_pset", tool.Ifc.get(), product=element, name="EPset_Parametric") + if representation_template == "LAYERSET_AXIS2": + axis = "AXIS2" + elif representation_template == "LAYERSET_AXIS3": + axis = "AXIS3" + ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=pset, properties={"LayerSetDirection": axis}) + elif representation_template == "PROFILESET" or representation_template.startswith("FLOW_SEGMENT_"): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + materials = tool.Ifc.get().by_type("IfcMaterial") + if materials: + material = materials[0] # Arbitrarily pick a material + else: + material = ifcopenshell.api.run("material.add_material", tool.Ifc.get(), name="Unknown") + if representation_template == "PROFILESET": + named_profiles = [p for p in tool.Ifc.get().by_type("IfcProfileDef") if p.ProfileName] + if named_profiles: + profile = named_profiles[0] + else: + size = 0.5 / unit_scale + profile = tool.Ifc.get().create_entity( + "IfcRectangleProfileDef", ProfileName="New Profile", ProfileType="AREA", XDim=size, YDim=size + ) + else: + # NOTE: defaults dims are in meters / mm + # for now default names are hardcoded to mm + if representation_template == "FLOW_SEGMENT_RECTANGULAR": + default_x_dim = 0.4 + default_y_dim = 0.2 + profile_name = f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}" + profile = tool.Ifc.get().create_entity( + "IfcRectangleProfileDef", + ProfileName=profile_name, + ProfileType="AREA", + XDim=default_x_dim / unit_scale, + YDim=default_y_dim / unit_scale, + ) + elif representation_template == "FLOW_SEGMENT_CIRCULAR": + default_diameter = 0.1 + profile_name = f"{props.ifc_class}-{default_diameter*1000}" + profile = tool.Ifc.get().create_entity( + "IfcCircleProfileDef", + ProfileName=profile_name, + ProfileType="AREA", + Radius=(default_diameter / 2) / unit_scale, + ) + elif representation_template == "FLOW_SEGMENT_CIRCULAR_HOLLOW": + default_diameter = 0.15 + default_thickness = 0.005 + profile_name = f"{props.ifc_class}-{default_diameter*1000}x{default_thickness*1000}" + profile = tool.Ifc.get().create_entity( + "IfcCircleHollowProfileDef", + ProfileName=profile_name, + ProfileType="AREA", + Radius=(default_diameter / 2) / unit_scale, + WallThickness=default_thickness, + ) + + rel = ifcopenshell.api.run( + "material.assign_material", tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet" + ) + profile_set = rel.RelatingMaterial + material_profile = ifcopenshell.api.run( + "material.add_profile", tool.Ifc.get(), profile_set=profile_set, material=material + ) + ifcopenshell.api.run( + "material.assign_profile", tool.Ifc.get(), material_profile=material_profile, profile=profile + ) + elif representation_template == "WINDOW": + with context.temp_override(active_object=obj): + bpy.ops.bim.add_window() + elif representation_template == "DOOR": + with context.temp_override(active_object=obj): + bpy.ops.bim.add_door() + elif representation_template == "STAIR": + with context.temp_override(active_object=obj): + bpy.ops.bim.add_stair() + elif representation_template == "RAILING": + with context.temp_override(active_object=obj): + bpy.ops.bim.add_railing() + elif representation_template == "ROOF": + with context.temp_override(active_object=obj): + bpy.ops.bim.add_roof() + + def draw(self, context): + props = context.scene.BIMRootProperties + self.layout.use_property_split = True + self.layout.use_property_decorate = False + prop_with_search(self.layout, props, "ifc_product", text="Definition") + prop_with_search(self.layout, props, "ifc_class", should_click_ok_to_validate=True) + ifc_predefined_types = root_prop.get_ifc_predefined_types(context.scene.BIMRootProperties, context) + if ifc_predefined_types: + prop_with_search(self.layout, props, "ifc_predefined_type") + if props.ifc_predefined_type == "USERDEFINED": + row = self.layout.row() + row.prop(props, "ifc_userdefined_type") + prop_with_search(self.layout, props, "representation_template", text="Representation") + if props.representation_template == "OBJ": + row = self.layout.row() + row.prop(props, "representation_obj", text="Object") + if props.representation_template != "EMPTY": + prop_with_search(self.layout, props, "contexts") + + +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): + 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 0986d80bb5..0de2f0e5ba 100644 --- a/src/bonsai/bonsai/bim/module/root/prop.py +++ b/src/bonsai/bonsai/bim/module/root/prop.py @@ -41,6 +41,12 @@ def get_ifc_predefined_types(self, context): return IfcClassData.data["ifc_predefined_types"] +def get_representation_template(self, context): + if not IfcClassData.is_loaded: + IfcClassData.load() + return IfcClassData.data["representation_template"] + + def refresh_classes(self, context): enum = get_ifc_classes(self, context) context.scene.BIMRootProperties.ifc_class = enum[0][0] @@ -109,12 +115,25 @@ def is_object_class_applicable(self, obj): return element.is_a("IfcTypeObject") == active_element.is_a("IfcTypeObject") +def poll_representation_obj(self, obj): + return obj.type == "MESH" and obj.data.polygons + + class BIMRootProperties(PropertyGroup): - contexts: EnumProperty(items=get_contexts, name="Contexts") + contexts: EnumProperty(items=get_contexts, name="Contexts", options=set()) ifc_product: EnumProperty(items=get_ifc_products, name="Products", update=refresh_classes) ifc_class: EnumProperty(items=get_ifc_classes, name="Class", update=refresh_predefined_types) ifc_predefined_type: EnumProperty(items=get_ifc_predefined_types, name="Predefined Type", default=None) ifc_userdefined_type: StringProperty(name="Userdefined Type") + representation_template: bpy.props.EnumProperty( + items=get_representation_template, name="Representation Template", default=0 + ) + representation_obj: bpy.props.PointerProperty( + type=bpy.types.Object, + name="Representation Object", + poll=poll_representation_obj, + description="The representation will be a tessellation of the selected object", + ) relating_class_object: PointerProperty( type=bpy.types.Object, name="Copy Class", diff --git a/src/bonsai/bonsai/bim/module/root/ui.py b/src/bonsai/bonsai/bim/module/root/ui.py index 995d16cdcc..c8cec94d57 100644 --- a/src/bonsai/bonsai/bim/module/root/ui.py +++ b/src/bonsai/bonsai/bim/module/root/ui.py @@ -83,8 +83,7 @@ class BIM_PT_class(Panel): def draw_class_dropdowns(self, context, ifc_predefined_types, is_reassigning_class=False): props = context.scene.BIMRootProperties layout = self.layout - if not is_reassigning_class: - prop_with_search(layout, props, "ifc_product") + prop_with_search(layout, props, "ifc_product") prop_with_search(layout, props, "ifc_class") if ifc_predefined_types: prop_with_search(layout, props, "ifc_predefined_type") diff --git a/src/bonsai/bonsai/bim/module/search/ui.py b/src/bonsai/bonsai/bim/module/search/ui.py index 05dfcb9e4d..a18a7e2409 100644 --- a/src/bonsai/bonsai/bim/module/search/ui.py +++ b/src/bonsai/bonsai/bim/module/search/ui.py @@ -102,7 +102,6 @@ class BIM_PT_colour_by_property(Panel): if props.max_mode == "MANUAL": row.prop(props, "max_value", text="") - row = self.layout.row(align=True) row.operator("bim.colour_by_property", icon="BRUSH_DATA") row.operator("bim.reset_object_colours") diff --git a/src/bonsai/bonsai/bim/module/sequence/operator.py b/src/bonsai/bonsai/bim/module/sequence/operator.py index 32c6b8fa33..47c3ac8cd3 100644 --- a/src/bonsai/bonsai/bim/module/sequence/operator.py +++ b/src/bonsai/bonsai/bim/module/sequence/operator.py @@ -155,32 +155,34 @@ class RemoveWorkPlan(bpy.types.Operator, tool.Ifc.Operator): core.remove_work_plan(tool.Ifc, work_plan=tool.Ifc.get().by_id(self.work_plan)) -class EnableEditingWorkPlan(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingWorkPlan(bpy.types.Operator): bl_idname = "bim.enable_editing_work_plan" bl_label = "Enable Editing Work Plan" bl_options = {"REGISTER", "UNDO"} work_plan: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.enable_editing_work_plan(tool.Sequence, work_plan=tool.Ifc.get().by_id(self.work_plan)) + return {"FINISHED"} -class DisableEditingWorkPlan(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingWorkPlan(bpy.types.Operator): bl_idname = "bim.disable_editing_work_plan" bl_options = {"REGISTER", "UNDO"} bl_label = "Disable Editing Work Plan" - def _execute(self, context): + def execute(self, context): core.disable_editing_work_plan(tool.Sequence) + return {"FINISHED"} -class EnableEditingWorkPlanSchedules(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingWorkPlanSchedules(bpy.types.Operator): bl_idname = "bim.enable_editing_work_plan_schedules" bl_label = "Enable Editing Work Plan Schedules" bl_options = {"REGISTER", "UNDO"} work_plan: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.enable_editing_work_plan_schedules(tool.Sequence, work_plan=tool.Ifc.get().by_id(self.work_plan)) return {"FINISHED"} @@ -269,14 +271,15 @@ class EnableEditingWorkSchedule(bpy.types.Operator): return {"FINISHED"} -class EnableEditingWorkScheduleTasks(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingWorkScheduleTasks(bpy.types.Operator): bl_idname = "bim.enable_editing_work_schedule_tasks" bl_label = "Enable Editing Work Schedule Tasks" bl_options = {"REGISTER", "UNDO"} work_schedule: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.enable_editing_work_schedule_tasks(tool.Sequence, work_schedule=tool.Ifc.get().by_id(self.work_schedule)) + return {"FINISHED"} class LoadTaskProperties(bpy.types.Operator): @@ -352,6 +355,8 @@ class RemoveTask(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingTaskTime(bpy.types.Operator, tool.Ifc.Operator): + # IFC operator is needed because operator is adding a new task time to IFC + # if it doesn't exist. bl_idname = "bim.enable_editing_task_time" bl_label = "Enable Editing Task Time" bl_options = {"REGISTER", "UNDO"} @@ -520,10 +525,10 @@ class AssignProcess(bpy.types.Operator, tool.Ifc.Operator): else: core.assign_input_products(tool.Ifc, tool.Sequence, tool.Spatial, task=tool.Ifc.get().by_id(self.task)) elif self.related_object_type == "CONTROL": - pass # TODO + self.report({"ERROR"}, "Assigning process control is not yet supported") # TODO -class UnassignProcess(bpy.types.Operator): +class UnassignProcess(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_process" bl_label = "Unassign Process" bl_options = {"REGISTER", "UNDO"} @@ -532,9 +537,6 @@ class UnassignProcess(bpy.types.Operator): related_object: bpy.props.IntProperty() resource: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): if self.related_object_type == "RESOURCE": core.unassign_resource( @@ -635,10 +637,13 @@ class ImportCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True - def execute(self, context): + def _execute(self, context): from ifc4d.csv4d2ifc import Csv2Ifc self.file = tool.Ifc.get() @@ -647,11 +652,10 @@ class ImportCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): csv2ifc.csv = self.filepath csv2ifc.file = self.file csv2ifc.execute() - print("Imported in %s seconds" % (time.time() - start)) - return {"FINISHED"} + self.report({"INFO"}, "Imported in %s seconds" % (time.time() - start)) -class ImportP6(bpy.types.Operator, ImportHelper): +class ImportP6(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_p6" bl_label = "Import P6" bl_options = {"REGISTER", "UNDO"} @@ -660,10 +664,13 @@ class ImportP6(bpy.types.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True - def execute(self, context): + def _execute(self, context): from ifc4d.p62ifc import P62Ifc self.file = IfcStore.get_file() @@ -673,11 +680,10 @@ class ImportP6(bpy.types.Operator, ImportHelper): p62ifc.file = self.file p62ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None p62ifc.execute() - print("Import finished in {:.2f} seconds".format(time.time() - start)) - return {"FINISHED"} + self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start)) -class ImportP6XER(bpy.types.Operator, ImportHelper): +class ImportP6XER(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_p6xer" bl_label = "Import P6 XER" bl_options = {"REGISTER", "UNDO"} @@ -686,10 +692,13 @@ class ImportP6XER(bpy.types.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True - def execute(self, context): + def _execute(self, context): from ifc4d.p6xer2ifc import P6XER2Ifc self.file = IfcStore.get_file() @@ -699,11 +708,10 @@ class ImportP6XER(bpy.types.Operator, ImportHelper): p6xer2ifc.file = self.file p6xer2ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None p6xer2ifc.execute() - print("Import finished in {:.2f} seconds".format(time.time() - start)) - return {"FINISHED"} + self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start)) -class ImportPP(bpy.types.Operator, ImportHelper): +class ImportPP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_pp" bl_label = "Import Powerproject .pp" bl_options = {"REGISTER", "UNDO"} @@ -712,10 +720,13 @@ class ImportPP(bpy.types.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True - def execute(self, context): + def _execute(self, context): from ifc4d.pp2ifc import PP2Ifc self.file = IfcStore.get_file() @@ -725,11 +736,10 @@ class ImportPP(bpy.types.Operator, ImportHelper): pp2ifc.file = self.file pp2ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None pp2ifc.execute() - print("Import finished in {:.2f} seconds".format(time.time() - start)) - return {"FINISHED"} + self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start)) -class ImportMSP(bpy.types.Operator, ImportHelper): +class ImportMSP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_msp" bl_label = "Import MSP" bl_options = {"REGISTER", "UNDO"} @@ -738,10 +748,13 @@ class ImportMSP(bpy.types.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True - def execute(self, context): + def _execute(self, context): from ifc4d.msp2ifc import MSP2Ifc self.file = IfcStore.get_file() @@ -751,8 +764,7 @@ class ImportMSP(bpy.types.Operator, ImportHelper): msp2ifc.file = self.file msp2ifc.work_plan = self.file.by_type("IfcWorkPlan")[0] if self.file.by_type("IfcWorkPlan") else None msp2ifc.execute() - print("Import finished in {:.2f} seconds".format(time.time() - start)) - return {"FINISHED"} + self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start)) class ExportMSP(bpy.types.Operator, ImportHelper): @@ -766,8 +778,11 @@ class ExportMSP(bpy.types.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True def execute(self, context): from ifc4d.ifc2msp import Ifc2Msp @@ -781,7 +796,7 @@ class ExportMSP(bpy.types.Operator, ImportHelper): ifc2msp.holiday_start_date = parser.parse(self.holiday_start_date).date() ifc2msp.holiday_finish_date = parser.parse(self.holiday_finish_date).date() ifc2msp.execute() - print("Export finished in {:.2f} seconds".format(time.time() - start)) + self.report({"INFO"}, "Export finished in {:.2f} seconds".format(time.time() - start)) return {"FINISHED"} @@ -796,8 +811,11 @@ class ExportP6(bpy.types.Operator, ImportHelper): @classmethod def poll(cls, context): - ifc_file = IfcStore.get_file() - return ifc_file is not None + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + return True def execute(self, context): from ifc4d.ifc2p6 import Ifc2P6 @@ -810,7 +828,7 @@ class ExportP6(bpy.types.Operator, ImportHelper): ifc2p6.holiday_start_date = parser.parse(self.holiday_start_date).date() ifc2p6.holiday_finish_date = parser.parse(self.holiday_finish_date).date() ifc2p6.execute() - print("Export finished in {:.2f} seconds".format(time.time() - start)) + self.report({"INFO"}, "Export finished in {:.2f} seconds".format(time.time() - start)) return {"FINISHED"} @@ -877,16 +895,13 @@ class RemoveWorkTime(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class AssignRecurrencePattern(bpy.types.Operator): +class AssignRecurrencePattern(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_recurrence_pattern" bl_label = "Assign Recurrence Pattern" bl_options = {"REGISTER", "UNDO"} work_time: bpy.props.IntProperty() recurrence_type: bpy.props.StringProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): core.assign_recurrence_pattern( tool.Ifc, work_time=tool.Ifc.get().by_id(self.work_time), recurrence_type=self.recurrence_type @@ -894,15 +909,12 @@ class AssignRecurrencePattern(bpy.types.Operator): return {"FINISHED"} -class UnassignRecurrencePattern(bpy.types.Operator): +class UnassignRecurrencePattern(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_recurrence_pattern" bl_label = "Unassign Recurrence Pattern" bl_options = {"REGISTER", "UNDO"} recurrence_pattern: bpy.props.IntProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): core.unassign_recurrence_pattern(tool.Ifc, recurrence_pattern=tool.Ifc.get().by_id(self.recurrence_pattern)) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/spatial/__init__.py b/src/bonsai/bonsai/bim/module/spatial/__init__.py index 99d9bae24c..97c49ab90f 100644 --- a/src/bonsai/bonsai/bim/module/spatial/__init__.py +++ b/src/bonsai/bonsai/bim/module/spatial/__init__.py @@ -33,19 +33,25 @@ classes = ( operator.ReferenceStructure, operator.RemoveContainer, operator.SelectContainer, + operator.SelectDecomposedElement, operator.SelectDecomposedElements, operator.SelectProduct, operator.SelectSimilarContainer, operator.SetContainerVisibility, operator.SetDefaultContainer, + operator.ToggleContainerElement, + operator.ToggleGrids, + operator.ToggleSpatialElements, prop.Element, prop.BIMObjectSpatialProperties, prop.BIMContainer, prop.BIMSpatialDecompositionProperties, + prop.BIMGridProperties, ui.BIM_PT_spatial, ui.BIM_UL_containers_manager, ui.BIM_UL_elements, ui.BIM_PT_spatial_decomposition, + ui.BIM_PT_grids, workspace.Hotkey, ) @@ -57,6 +63,7 @@ def register(): bpy.types.Scene.BIMSpatialDecompositionProperties = bpy.props.PointerProperty( type=prop.BIMSpatialDecompositionProperties ) + bpy.types.Scene.BIMGridProperties = bpy.props.PointerProperty(type=prop.BIMGridProperties) def unregister(): @@ -64,3 +71,4 @@ def unregister(): bpy.utils.unregister_tool(workspace.SpatialTool) del bpy.types.Object.BIMObjectSpatialProperties del bpy.types.Scene.BIMSpatialDecompositionProperties + del bpy.types.Scene.BIMGridProperties diff --git a/src/bonsai/bonsai/bim/module/spatial/decorator.py b/src/bonsai/bonsai/bim/module/spatial/decorator.py new file mode 100644 index 0000000000..739ecdaf10 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/spatial/decorator.py @@ -0,0 +1,139 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 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 blf +import gpu +import bmesh +import bonsai.tool as tool +from bpy.types import SpaceView3D +from mathutils import Vector +from gpu_extras.batch import batch_for_shader +from bpy_extras.view3d_utils import location_3d_to_region_2d + + +class GridDecorator: + 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_text, (context,), "WINDOW", "POST_PIXEL")) + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (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_text(self, context): + self.addon_prefs = tool.Blender.get_addon_preferences() + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + font_id = 0 + blf.size(font_id, 12) + blf.enable(font_id, blf.SHADOW) + + for axis in context.scene.BIMGridProperties.grid_axes: + if not (obj := axis.obj) or obj.hide_get() == True: + continue + if obj.select_get() and context.mode != "OBJECT": + continue + + tag = obj.name.split("/")[-1] + matrix_world = obj.matrix_world + v1 = matrix_world @ obj.data.vertices[0].co + v2 = matrix_world @ obj.data.vertices[1].co + + color = selected_elements_color if obj.select_get() else unselected_elements_color + blf.color(font_id, *color) + + coords_2d = location_3d_to_region_2d(context.region, context.region_data, v1) + if coords_2d: + w, h = blf.dimensions(font_id, tag) + coords_2d -= Vector((w * 0.5, h * 0.5)) + blf.position(font_id, coords_2d[0], coords_2d[1], 0) + blf.draw(font_id, tag) + + coords_2d = location_3d_to_region_2d(context.region, context.region_data, v2) + if coords_2d: + w, h = blf.dimensions(font_id, tag) + coords_2d -= Vector((w * 0.5, h * 0.5)) + blf.position(font_id, coords_2d[0], coords_2d[1], 0) + blf.draw(font_id, tag) + blf.disable(font_id, blf.SHADOW) + + def draw(self, context): + self.addon_prefs = tool.Blender.get_addon_preferences() + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + + self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + self.line_shader.bind() # required to be able to change uniforms of the shader + # POLYLINE_UNIFORM_COLOR specific uniforms + self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + self.line_shader.uniform_float("lineWidth", 2.0) + + # general shader + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + + selected_verts = [] + selected_edges = [] + unselected_verts = [] + unselected_edges = [] + for axis in context.scene.BIMGridProperties.grid_axes: + if (obj := axis.obj) and obj.hide_get() == False: + if obj.select_get(): + if context.mode != "OBJECT": + continue + edges = selected_edges + verts = selected_verts + else: + edges = unselected_edges + verts = unselected_verts + i = len(verts) + edges.append([i, i + 1]) + matrix_world = obj.matrix_world + v1 = matrix_world @ obj.data.vertices[0].co + v2 = matrix_world @ obj.data.vertices[1].co + verts.extend([v1, v2]) + + if unselected_verts: + self.draw_batch("LINES", unselected_verts, unselected_elements_color, unselected_edges) + + if selected_verts: + self.draw_batch("LINES", selected_verts, selected_elements_color, selected_edges) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 9ba7831940..f0c117528b 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -76,16 +76,18 @@ class DereferenceStructure(bpy.types.Operator, tool.Ifc.Operator): class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_container" bl_label = "Assign Container" - bl_description = "Assign current default container to the selected objects" + bl_description = "Assign the selected objects to the selected container" bl_options = {"REGISTER", "UNDO"} + container: bpy.props.IntProperty(options={"SKIP_SAVE"}) def _execute(self, context): props = context.active_object.BIMObjectSpatialProperties - if (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)): - for element_obj in context.selected_objects: - core.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj - ) + if self.container: + container = tool.Ifc.get().by_id(self.container) + elif (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)): + pass + for element_obj in context.selected_objects: + core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj) class EnableEditingContainer(bpy.types.Operator, tool.Ifc.Operator): @@ -243,6 +245,35 @@ class DeleteContainer(bpy.types.Operator, tool.Ifc.Operator): core.delete_container(tool.Ifc, tool.Spatial, tool.Geometry, container=tool.Ifc.get().by_id(self.container)) +class ToggleContainerElement(bpy.types.Operator): + bl_idname = "bim.toggle_container_element" + bl_label = "Toggle Container Element" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Toggle children\nALT+CLICK to recursively toggle children" + element_index: bpy.props.IntProperty() + is_recursive: bpy.props.BoolProperty(name="Is Recursive", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + if event.type == "LEFTMOUSE" and event.alt: + self.is_recursive = True + return self.execute(context) + + def execute(self, context): + core.toggle_container_element(tool.Spatial, element_index=self.element_index, is_recursive=self.is_recursive) + return {"FINISHED"} + + +class SelectDecomposedElement(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.select_decomposed_element" + bl_label = "Select Decomposed Element" + bl_options = {"REGISTER", "UNDO"} + element: bpy.props.IntProperty() + + def _execute(self, context): + if self.element: + core.select_decomposed_element(tool.Ifc, tool.Spatial, element=tool.Ifc.get().by_id(self.element)) + + class SelectDecomposedElements(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.select_decomposed_elements" bl_label = "Select Children" @@ -260,26 +291,61 @@ class SelectDecomposedElements(bpy.types.Operator, tool.Ifc.Operator): return self.execute(context) def _execute(self, context): - ifc_class = relating_type = is_untyped = None - if self.should_filter: - active_element = context.scene.BIMSpatialDecompositionProperties.active_element - if active_element.is_class: - ifc_class = active_element.name - elif relating_type := active_element.ifc_definition_id: - ifc_class = active_element.ifc_class - relating_type = tool.Ifc.get().by_id(relating_type) - else: - ifc_class = active_element.ifc_class - is_untyped = True - element_filter = context.scene.BIMSpatialDecompositionProperties.element_filter - core.select_decomposed_elements( - tool.Spatial, - container=tool.Ifc.get().by_id(self.container), - ifc_class=ifc_class, - relating_type=relating_type, - is_untyped=is_untyped, - element_filter = element_filter - ) + ifc_file = tool.Ifc.get() + container = ifc_file.by_id(self.container) + props = context.scene.BIMSpatialDecompositionProperties + element_filter = props.element_filter + active_element = props.active_element + + if not self.should_filter and not element_filter: + tool.Spatial.select_products(tool.Spatial.get_decomposed_elements(container)) + return + + if props.element_mode == "TYPE": + if active_element.type == "OCCURRENCE": + if obj := tool.Ifc.get_object(ifc_file.by_id(active_element.ifc_definition_id)): + tool.Blender.set_active_object(obj) + return + + ifc_class = relating_type = None + is_untyped = False + + if self.should_filter: + if active_element.type == "CLASS": + ifc_class = active_element.name + elif active_element.type == "TYPE": + ifc_class = active_element.ifc_class + if ifc_id := active_element.ifc_definition_id: + relating_type = ifc_file.by_id(ifc_id) + + elements = tool.Spatial.get_decomposed_elements(container) + elements = tool.Spatial.filter_elements(elements, ifc_class, relating_type, is_untyped, element_filter) + tool.Spatial.select_products(elements) + elif props.element_mode == "DECOMPOSITION": + occurrence = ifc_file.by_id(active_element.ifc_definition_id) + elements = ifcopenshell.util.element.get_decomposition(occurrence) + elements.add(occurrence) + tool.Spatial.select_products(elements) + elif props.element_mode == "CLASSIFICATION": + if active_element.type == "OCCURRENCE": + if obj := tool.Ifc.get_object(ifc_file.by_id(active_element.ifc_definition_id)): + tool.Blender.set_active_object(obj) + return + + if active_element.type == "CLASSIFICATION": + identification = active_element.identification + elements = tool.Spatial.get_decomposed_elements(container) + + def filter_element(element: ifcopenshell.entity_instance) -> bool: + references = ifcopenshell.util.classification.get_references(element) + if identification == "Unclassified": + if not references: + return True + elif any([r for r in references if r[1].startswith(identification)]): + return True + return False + + tool.Spatial.select_products(filter(filter_element, elements)) class SetDefaultContainer(bpy.types.Operator, tool.Ifc.Operator): @@ -338,3 +404,33 @@ class SetContainerVisibility(bpy.types.Operator, tool.Ifc.Operator): collection.hide_viewport = should_hide if self.should_include_children: queue.extend(ifcopenshell.util.element.get_parts(container)) + + +class ToggleGrids(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.toggle_grids" + bl_label = "Toggle Grids" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Show or hide grids and grid axes" + is_visible: bpy.props.BoolProperty(name="Is Visible", default=False, options={"SKIP_SAVE"}) + + def _execute(self, context): + for element in tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis"): + if obj := tool.Ifc.get_object(element): + obj.hide_set(not self.is_visible) + + +class ToggleSpatialElements(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.toggle_spatial_elements" + bl_label = "Toggle Spatial Elements" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Show or hide spatial elements, such as buildings, sites, etc" + is_visible: bpy.props.BoolProperty(name="Is Visible", default=False, options={"SKIP_SAVE"}) + + def _execute(self, context): + if tool.Ifc.get().schema == "IFC2X3": + elements = tool.Ifc.get().by_type("IfcSpatialStructureElement") + else: + elements = tool.Ifc.get().by_type("IfcSpatialElement") + for element in elements: + if obj := tool.Ifc.get_object(element): + obj.hide_set(not self.is_visible) diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 12384f08c2..e1cc04b1b9 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -17,7 +17,7 @@ # along with Bonsai. If not, see . import bpy -from bonsai.bim.prop import StrProperty, Attribute +from bonsai.bim.prop import ObjProperty from bonsai.bim.module.spatial.data import SpatialDecompositionData from bpy.types import PropertyGroup from bpy.props import ( @@ -31,8 +31,11 @@ from bpy.props import ( CollectionProperty, ) import bonsai.tool as tool +import bonsai.bim.handler +import bonsai.core.geometry import ifcopenshell import ifcopenshell.util.element +import ifcopenshell.util.unit def get_subelement_class(self, context): @@ -42,12 +45,19 @@ def get_subelement_class(self, context): def update_elevation(self, context): + try: + elevation = float(self.elevation) + if self.elevation != str(elevation): + self.elevation = str(elevation) + return + except: + elevation = 0 if ifc_definition_id := self.ifc_definition_id: entity = tool.Ifc.get().by_id(ifc_definition_id) - obj = tool.Ifc.get_object(entity) - if not obj: - return - obj.location.z = self.elevation + if obj := tool.Ifc.get_object(entity): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + obj.matrix_world[2][3] = elevation * unit_scale + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj) def update_name(self, context): @@ -57,6 +67,8 @@ def update_name(self, context): def update_active_container_index(self, context): SpatialDecompositionData.data["subelement_class"] = SpatialDecompositionData.subelement_class() + if tool.Blender.get_enum_safe(self, "subelement_class") is None: + self.subelement_class = SpatialDecompositionData.data["subelement_class"][0][0] tool.Spatial.load_contained_elements() @@ -64,6 +76,10 @@ def update_should_include_children(self, context): tool.Spatial.load_contained_elements() +def update_element_mode(self, context): + tool.Spatial.load_contained_elements() + + def update_container_obj(self, context): if self.container_obj is None or not (obj := context.active_object): return @@ -82,6 +98,48 @@ def update_container_obj(self, context): self.container_obj = None +def update_grid_is_locked(self, context): + if not tool.Ifc.get(): + return + if tool.Ifc.get().schema in ("IFC2X3", "IFC4"): + elements = tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis") + else: + elements = tool.Ifc.get().by_type("IfcPositioningElement") + for element in elements: + if obj := tool.Ifc.get_object(element): + if self.is_locked: + tool.Geometry.lock_object(obj) + else: + tool.Geometry.unlock_object(obj) + # Need to update ViewportData.mode. + bonsai.bim.handler.refresh_ui_data() + + +def update_spatial_is_locked(self, context): + if not tool.Ifc.get(): + return + if tool.Ifc.get().schema == "IFC2X3": + elements = tool.Ifc.get().by_type("IfcSpatialStructureElement") + else: + elements = tool.Ifc.get().by_type("IfcSpatialElement") + for element in elements: + if obj := tool.Ifc.get_object(element): + if self.is_locked: + tool.Geometry.lock_object(obj) + else: + tool.Geometry.unlock_object(obj) + # Need to update ViewportData.mode. + bonsai.bim.handler.refresh_ui_data() + + +def update_spatial_is_visible(self: "BIMSpatialDecompositionProperties", context: bpy.types.Context) -> None: + bpy.ops.bim.toggle_spatial_elements(is_visible=self.is_visible) + + +def update_grid_is_visible(self: "BIMGridProperties", context: bpy.types.Context) -> None: + bpy.ops.bim.toggle_grids(is_visible=self.is_visible) + + def poll_container_obj(self, obj): return obj is None or tool.Ifc.get_entity(obj) @@ -98,7 +156,7 @@ class BIMContainer(PropertyGroup): ifc_class: StringProperty(name="IFC Class") description: StringProperty(name="Description") long_name: StringProperty(name="Long Name") - elevation: FloatProperty(name="Elevation", subtype="DISTANCE", update=update_elevation) + elevation: StringProperty(name="Elevation", update=update_elevation) level_index: IntProperty(name="Level Index") has_children: BoolProperty(name="Has Children") is_expanded: BoolProperty(name="Is Expanded") @@ -107,23 +165,58 @@ class BIMContainer(PropertyGroup): class Element(PropertyGroup): name: StringProperty(name="Name") - ifc_class: StringProperty(name="Name") - is_class: BoolProperty(name="Is Class", default=False) - is_type: BoolProperty(name="Is Type", default=False) - ifc_definition_id: IntProperty(name="IFC Definition ID") + ifc_class: StringProperty(name="Name", description="Type or element IFC class, empty if 'type' is 'CLASS'") + identification: StringProperty(name="Identification", description="Identification if 'type' is 'CLASSIFICATION'") + ifc_definition_id: IntProperty( + name="IFC Definition ID", + description="ID of the element type / occurrence. 0 if 'type' is 'CLASS' or if it's 'TYPE' but it represents untyped elements", + ) + level: IntProperty(name="Level", default=0) + has_children: BoolProperty(name="Has Children", default=False) total: IntProperty(name="Total") + is_expanded: BoolProperty(name="Is Expanded", default=False) + type: EnumProperty( + name="Element Type", + items=( + ("CLASS", "CLASS", "CLASS"), + ("TYPE", "TYPE", "TYPE"), + ("CLASSIFICATION", "CLASSIFICATION", "CLASSIFICATION"), + ("OCCURRENCE", "OCCURRENCE", "OCCURRENCE"), + ), + ) class BIMSpatialDecompositionProperties(PropertyGroup): + is_locked: BoolProperty( + name="Is Locked", + description="Prevent all spatial elements from being edited, removed, duplicated", + default=True, + update=update_spatial_is_locked, + ) + is_visible: BoolProperty( + name="Is Visible", + description="Show or hide spatial elements, such as buildings, sites, etc", + default=True, + update=update_spatial_is_visible, + ) container_filter: StringProperty(name="Container Filter", default="", options={"TEXTEDIT_UPDATE"}) containers: CollectionProperty(name="Containers", type=BIMContainer) contracted_containers: StringProperty(name="Contracted containers", default="[]") - expanded_containers: StringProperty(name="Expanded containers", default="[]") active_container_index: IntProperty(name="Active Container Index", update=update_active_container_index) element_filter: StringProperty(name="Element Filter", default="", options={"TEXTEDIT_UPDATE"}) elements: CollectionProperty(name="Elements", type=Element) + expanded_elements: StringProperty(name="Expanded Elements", default="{}") active_element_index: IntProperty(name="Active Element Index") total_elements: IntProperty(name="Total Elements") + element_mode: EnumProperty( + name="Element Mode", + items=( + ("TYPE", "Class > Type > Occurrence", "Show a breakdown by IfcClass, relating type, and occurrence"), + ("DECOMPOSITION", "Element Decomposition", "Show a breakdown by element decomposition"), + ("CLASSIFICATION", "Element Classification", "Show a breakdown by element classification"), + ), + update=update_element_mode, + ) subelement_class: bpy.props.EnumProperty(items=get_subelement_class, name="Subelement Class") default_container: IntProperty(name="Default Container", default=0) should_include_children: BoolProperty( @@ -139,3 +232,19 @@ class BIMSpatialDecompositionProperties(PropertyGroup): def active_element(self): if self.elements and self.active_element_index < len(self.elements): return self.elements[self.active_element_index] + + +class BIMGridProperties(PropertyGroup): + is_locked: BoolProperty( + name="Is Locked", + description="Prevent all grids and grid axes from being edited, removed, duplicated", + default=True, + update=update_grid_is_locked, + ) + is_visible: BoolProperty( + name="Is Visible", + description="Show or hide grids and grid axes", + default=True, + update=update_grid_is_visible, + ) + grid_axes: CollectionProperty(name="Grid Axes", type=ObjProperty) diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index a7d3e0a51a..a1d25b8c6f 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -95,13 +95,22 @@ class BIM_PT_spatial_decomposition(Panel): bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "scene" - bl_parent_id = "BIM_PT_tab_spatial_decomposition" - bl_options = {"HIDE_HEADER"} + bl_parent_id = "BIM_PT_tab_spatial" + bl_options = {"HEADER_LAYOUT_EXPAND"} @classmethod def poll(cls, context): return tool.Ifc.get() + def draw_header(self, context): + props = context.scene.BIMSpatialDecompositionProperties + 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" + row.prop(props, "is_visible", text="", icon=icon) + icon = "VIEW_LOCKED" if props.is_locked else "VIEW_UNLOCKED" + row.prop(props, "is_locked", text="", icon=icon) + def draw(self, context): if not SpatialDecompositionData.is_loaded: SpatialDecompositionData.load() @@ -159,19 +168,32 @@ class BIM_PT_spatial_decomposition(Panel): if not self.props.total_elements: row = self.layout.row(align=True) - row.label(text=f"{self.props.active_container.ifc_class} > No Contained Elements", icon="FILE_3D") + row.label(text=f"{self.props.active_container.ifc_class} > No Elements", icon="FILE_3D") + row.operator("bim.assign_container", icon="FOLDER_REDIRECT", text="").container = ifc_definition_id + + row = self.layout.row(align=True) + row.prop(self.props, "element_mode", text="", icon="FILEBROWSER") row.prop(self.props, "should_include_children", text="", icon="OUTLINER") return row = self.layout.row(align=True) row.label( - text=f"{self.props.active_container.ifc_class} > {self.props.total_elements} Contained Elements", + text=f"{self.props.active_container.ifc_class} > {self.props.total_elements} Elements", icon="FILE_3D", ) - row.prop(self.props, "should_include_children", text="", icon="OUTLINER") + row.operator("bim.assign_container", icon="FOLDER_REDIRECT", text="").container = ifc_definition_id + op = row.operator("bim.select_decomposed_element", icon="OBJECT_DATA", text="") + if active_element := self.props.active_element: + op.element = active_element.ifc_definition_id + else: + op.element = 0 op = row.operator("bim.select_decomposed_elements", icon="RESTRICT_SELECT_OFF", text="") op.container = ifc_definition_id + row = self.layout.row(align=True) + row.prop(self.props, "element_mode", text="", icon="FILEBROWSER") + row.prop(self.props, "should_include_children", text="", icon="OUTLINER") + self.layout.template_list( "BIM_UL_elements", "", @@ -183,6 +205,29 @@ class BIM_PT_spatial_decomposition(Panel): ) +class BIM_PT_grids(Panel): + bl_label = "Grids" + bl_idname = "BIM_PT_grids" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_spatial" + bl_options = {"HEADER_LAYOUT_EXPAND"} + + def draw(self, context): + self.layout.row().operator("mesh.add_grid", icon="ADD", text="Add Grids") + + def draw_header(self, context): + props = context.scene.BIMGridProperties + 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" + row.prop(props, "is_visible", text="", icon=icon) + icon = "VIEW_LOCKED" if props.is_locked else "VIEW_UNLOCKED" + row.prop(props, "is_locked", text="", icon=icon) + + class BIM_UL_containers_manager(UIList): def __init__(self): self.use_filter_show = True @@ -190,7 +235,6 @@ class BIM_UL_containers_manager(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) - self.draw_hierarchy(row, item) icon = { "IfcProject": "FILE", "IfcSite": "WORLD", @@ -204,12 +248,18 @@ class BIM_UL_containers_manager(UIList): "IfcRailwayPart": "MOD_FLUID", "IfcRoadPart": "MOD_FLUID", }.get(item.ifc_class, "META_PLANE") - row.prop(item, "name", emboss=False, text="", icon=icon) + split = row.split(factor=0.85) if item.long_name: - row.prop(item, "long_name", emboss=False, text="") - col = row.column() - col.alignment = "RIGHT" - col.prop(item, "elevation", emboss=False, text="") + split2 = split.split(factor=0.7) + row = split2.row(align=True) + self.draw_hierarchy(row, item) + row.prop(item, "name", emboss=False, text="", icon=icon) + split2.prop(item, "long_name", emboss=False, text="") + else: + row = split.row(align=True) + self.draw_hierarchy(row, item) + row.prop(item, "name", emboss=False, text="", icon=icon) + split.prop(item, "elevation", emboss=False, text="") def draw_hierarchy(self, row, item): if item.level_index: @@ -258,18 +308,19 @@ class BIM_UL_elements(UIList): def __init__(self): self.use_filter_show = True - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + 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): if item: row = layout.row(align=True) - if item.is_class: - row.label(text="", icon="DISCLOSURE_TRI_DOWN") - row.label(text=item.name) - col = row.column() - col.alignment = "RIGHT" - col.label(text=str(item.total)) - elif item.is_type: + for _ in range(item.level): row.label(text="", icon="BLANK1") - row.label(text=item.name) + if item.has_children: + self.draw_toggle(row, item.is_expanded, index) + row.label(text=item.name) + if item.total: col = row.column() col.alignment = "RIGHT" col.label(text=str(item.total)) diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index 22dd98b7ba..d808fdf0f4 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -356,14 +356,14 @@ class BrowseExternalStyle(bpy.types.Operator): return {"FINISHED"} -class ActivateExternalStyle(bpy.types.Operator, tool.Ifc.Operator): +class ActivateExternalStyle(bpy.types.Operator): bl_idname = "bim.activate_external_style" bl_label = "Activate External Style" bl_options = {"REGISTER", "UNDO", "INTERNAL"} material_name: bpy.props.StringProperty(name="Material Name", default="") - def _execute(self, context): + def execute(self, context): if not self.material_name: material = context.active_object.active_material else: @@ -403,6 +403,7 @@ class ActivateExternalStyle(bpy.types.Operator, tool.Ifc.Operator): if material.use_nodes: tool.Blender.copy_node_graph(material, db["data_block"]) bpy.data.materials.remove(db["data_block"]) + return {"FINISHED"} def copy_material_attributes(self, source, target): ID_properties = bpy.types.ID.bl_rna.properties @@ -444,33 +445,37 @@ class ActivateExternalStyle(bpy.types.Operator, tool.Ifc.Operator): set_prop(prop_name) -class DisableEditingStyles(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingStyles(bpy.types.Operator): bl_idname = "bim.disable_editing_styles" bl_options = {"REGISTER", "UNDO"} bl_label = "Disable Editing Styles" - def _execute(self, context): + def execute(self, context): core.disable_editing_styles(tool.Style) + return {"FINISHED"} -class LoadStyles(bpy.types.Operator, tool.Ifc.Operator): +class LoadStyles(bpy.types.Operator): bl_idname = "bim.load_styles" bl_label = "Load Styles" bl_options = {"REGISTER", "UNDO"} style_type: bpy.props.StringProperty() - def _execute(self, context): - core.load_styles(tool.Style, style_type=self.style_type) + def execute(self, context): + style_type = self.style_type if self.style_type else context.scene.BIMStylesProperties.style_type + core.load_styles(tool.Style, style_type=style_type) + return {"FINISHED"} -class SelectByStyle(bpy.types.Operator, tool.Ifc.Operator): +class SelectByStyle(bpy.types.Operator): bl_idname = "bim.select_by_style" bl_label = "Select By Style" bl_options = {"REGISTER", "UNDO"} style: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.select_by_style(tool.Style, tool.Spatial, style=tool.Ifc.get().by_id(self.style)) + return {"FINISHED"} class ChooseTextureMapPath(bpy.types.Operator): @@ -542,6 +547,8 @@ class EnableAddingPresentationStyle(bpy.types.Operator): @classmethod def poll(cls, context): + if not tool.Ifc.get(): + return False style_type = tool.Style.get_active_style_type() if style_type != "IfcSurfaceStyle": cls.poll_message_set(f"Adding {style_type} is not currently supported.") @@ -633,14 +640,14 @@ class AddPresentationStyle(bpy.types.Operator, tool.Ifc.Operator): core.load_styles(tool.Style, style_type=props.style_type) -class EnableEditingSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingSurfaceStyle(bpy.types.Operator): bl_idname = "bim.enable_editing_surface_style" bl_label = "Enable Editing Surface Style" bl_options = {"REGISTER", "UNDO"} style: bpy.props.IntProperty(default=0) ifc_class: bpy.props.StringProperty(default="") - def _execute(self, context): + def execute(self, context): props = bpy.context.scene.BIMStylesProperties style = tool.Ifc.get().by_id(self.style) props.is_editing_style = self.style @@ -678,6 +685,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): and active_style_type != "Shading" ): tool.Style.switch_shading(material, "Shading") + return {"FINISHED"} class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/tester/operator.py b/src/bonsai/bonsai/bim/module/tester/operator.py index 50c710accb..cd466bc377 100644 --- a/src/bonsai/bonsai/bim/module/tester/operator.py +++ b/src/bonsai/bonsai/bim/module/tester/operator.py @@ -37,7 +37,12 @@ class ExecuteIfcTester(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): props = context.scene.IfcTesterProperties - return (props.ifc_files.single_file or props.should_load_from_memory) and props.specs + if not props.should_load_from_memory and not props.ifc_files.single_file: + cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.") + return False + if not props.specs: + return False + return True def execute(self, context): props = context.scene.IfcTesterProperties diff --git a/src/bonsai/bonsai/bim/module/type/__init__.py b/src/bonsai/bonsai/bim/module/type/__init__.py index 445c9e1e28..f0e5546740 100644 --- a/src/bonsai/bonsai/bim/module/type/__init__.py +++ b/src/bonsai/bonsai/bim/module/type/__init__.py @@ -26,7 +26,6 @@ classes = ( operator.DisableEditingType, operator.DuplicateType, operator.EnableEditingType, - operator.PurgeUnusedTypes, 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 05f4d0ff54..605ab97c25 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -53,15 +53,12 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator): obj.name = tool.Model.generate_occurrence_name(type, element.is_a()) -class UnassignType(bpy.types.Operator): +class UnassignType(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_type" bl_label = "Unassign Type" bl_options = {"REGISTER", "UNDO"} related_object: bpy.props.StringProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): def exclude_callback(attribute): return attribute.is_a("IfcProfileDef") and attribute.ProfileName @@ -74,11 +71,10 @@ class UnassignType(bpy.types.Operator): continue ifcopenshell.api.run("type.unassign_type", self.file, related_objects=[element]) - active_representation = tool.Geometry.get_active_representation(obj) - active_context = active_representation.ContextOfItems - new_active_representation = None - if element.Representation: + new_active_representation = None + active_representation = tool.Geometry.get_active_representation(obj) + active_context = active_representation.ContextOfItems representations = [] for representation in element.Representation.Representations: resolved_representation = ifcopenshell.util.representation.resolve_representation(representation) @@ -97,16 +93,16 @@ class UnassignType(bpy.types.Operator): new_active_representation = copied_representation element.Representation.Representations = representations - if new_active_representation: - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=new_active_representation, - should_reload=False, - is_global=False, - should_sync_changes_first=False, - ) + if new_active_representation: + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=new_active_representation, + should_reload=False, + is_global=False, + should_sync_changes_first=False, + ) return {"FINISHED"} @@ -414,8 +410,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class="IfcWindowType" if tool.Ifc.get_schema() != "IFC2X3" else "IfcWindowStyle", should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_window(obj=obj.name) + with context.temp_override(active_object=obj): + bpy.ops.bim.add_window() elif template == "DOOR": mesh = bpy.data.meshes.new(name) @@ -429,8 +425,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class="IfcDoorType" if tool.Ifc.get_schema() != "IFC2X3" else "IfcDoorStyle", should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_door(obj=obj.name) + with context.temp_override(active_object=obj): + bpy.ops.bim.add_door() elif template == "STAIR": mesh = bpy.data.meshes.new(name) @@ -444,8 +440,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class=ifc_class, should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_stair() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_stair() elif template == "RAILING": mesh = bpy.data.meshes.new(name) @@ -460,8 +456,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): should_add_representation=True, context=body, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_railing() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_railing() elif template == "ROOF": mesh = bpy.data.meshes.new(name) @@ -476,8 +472,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): should_add_representation=True, context=body, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_roof() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_roof() bpy.ops.bim.load_type_thumbnails(ifc_class=ifc_class) props.type_class = props.type_class @@ -519,12 +515,12 @@ class RenameType(bpy.types.Operator, tool.Ifc.Operator): self.layout.prop(self, "name") -class AutoRenameOccurrences(bpy.types.Operator, tool.Ifc.Operator): +class AutoRenameOccurrences(bpy.types.Operator): bl_idname = "bim.auto_rename_occurrences" bl_label = "Auto Rename Occurrences" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): obj = context.active_object element_type = tool.Ifc.get_entity(obj) if element_type and element_type.is_a("IfcTypeObject"): @@ -533,6 +529,7 @@ class AutoRenameOccurrences(bpy.types.Operator, tool.Ifc.Operator): occurrence.Name = tool.Model.generate_occurrence_name(element_type, occurrence.is_a()) if obj: tool.Root.set_object_name(obj, occurrence) + return {"FINISHED"} class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): @@ -559,13 +556,3 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): context.scene.BIMModelProperties.ifc_class = new.is_a() context.scene.BIMModelProperties.relating_type_id = str(new_obj.BIMObjectProperties.ifc_definition_id) return {"FINISHED"} - - -class PurgeUnusedTypes(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.purge_unused_types" - bl_label = "Purge Unused Types" - bl_options = {"REGISTER", "UNDO"} - - def _execute(self, context): - purged_types = core.purge_unused_types(tool.Ifc, tool.Type, tool.Geometry) - self.report({"INFO"}, f"{purged_types} types were purged.") diff --git a/src/bonsai/bonsai/bim/module/void/operator.py b/src/bonsai/bonsai/bim/module/void/operator.py index 3f30028e56..80a5be7b43 100644 --- a/src/bonsai/bonsai/bim/module/void/operator.py +++ b/src/bonsai/bonsai/bim/module/void/operator.py @@ -30,10 +30,11 @@ from bonsai.bim.module.model.opening import FilledOpeningGenerator class AddOpening(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_opening" - bl_label = "Add Opening" + bl_label = "Apply Opening" bl_options = {"REGISTER", "UNDO"} bl_description = ( - "Adds an opening to an element.\n" "Need to select two elements, order of selection is not important" + "Apply an Opening object on an Element. " + "The Element and the Opening to be applied should be selected. The order of selection is not important" ) @classmethod @@ -43,7 +44,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): return True def _execute(self, context): - props = context.scene.BIMModelProperties selected_objects = context.selected_objects target_object = selected_objects[0] @@ -176,16 +176,13 @@ class RemoveOpening(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class AddFilling(bpy.types.Operator): +class AddFilling(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_filling" bl_label = "Add Filling" bl_options = {"REGISTER", "UNDO"} opening: bpy.props.StringProperty() obj: bpy.props.StringProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): obj = context.scene.objects.get(self.obj, context.active_object) opening = context.scene.objects.get(self.opening, context.scene.VoidProperties.desired_opening) diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 4ec3abff11..f101b06d5a 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -868,7 +868,8 @@ def update_enum_property_search_prop(self, context): if self.first_launch: self.first_launch = False else: - context.window.screen = context.window.screen + if not self.should_click_ok_to_validate: + context.window.screen = context.window.screen if predefined_type: try: setattr(context.data, "ifc_predefined_type", predefined_type) @@ -879,7 +880,8 @@ def update_enum_property_search_prop(self, context): class BIM_OT_enum_property_search(bpy.types.Operator): bl_idname = "bim.enum_property_search" - bl_label = "Search For Property" + bl_label = "Search" + bl_description = "Search For Property" bl_options = {"REGISTER", "UNDO"} first_launch: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) dummy_name: bpy.props.StringProperty(name="Property", update=update_enum_property_search_prop) @@ -887,6 +889,7 @@ class BIM_OT_enum_property_search(bpy.types.Operator): collection_identifiers: bpy.props.CollectionProperty(type=StrProperty) collection_predefined_types: bpy.props.CollectionProperty(type=StrProperty) prop_name: bpy.props.StringProperty() + should_click_ok_to_validate: bpy.props.BoolProperty(default=False) def invoke(self, context, event): self.clear_collections() diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 809dcd9e69..853995ec64 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -48,6 +48,8 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import Any, Union, Literal, get_args, TYPE_CHECKING +from typing_extensions import assert_never cwd = os.path.dirname(os.path.realpath(__file__)) @@ -248,13 +250,19 @@ def get_display_name(self): return f"{name}, {unit_symbol}" +AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file"] + + class Attribute(PropertyGroup): tooltip = "`Right Click > IFC Description` to read the attribute description and online documentation" name: StringProperty(name="Name") display_name: StringProperty(name="Display Name", get=get_display_name) description: StringProperty(name="Description") ifc_class: StringProperty(name="Ifc Class") - data_type: StringProperty(name="Data Type") + data_type: EnumProperty( # type: ignore [reportRedeclaration] + name="Data Type", + items=[(i, i, "") for i in get_args(AttributeDataType)], + ) string_value: StringProperty(name="Value", update=update_attribute_value, description=tooltip) bool_value: BoolProperty(name="Value", update=update_attribute_value, description=tooltip) int_value: IntProperty( @@ -288,8 +296,12 @@ class Attribute(PropertyGroup): value_max: FloatProperty(description="This is used to validate int_value and float_value") 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") - def get_value(self): + if TYPE_CHECKING: + data_type: AttributeDataType + + def get_value(self) -> Union[str, float, int, bool, None]: if self.is_optional and self.is_null: return None if self.data_type == "string": @@ -298,37 +310,43 @@ class Attribute(PropertyGroup): return [f.name for f in self.filepath_value.file_list] return getattr(self, str(self.get_value_name()), None) - def get_value_default(self): - if self.data_type == "string": + def get_value_default(self) -> Union[str, float, int, bool]: + data_type = self.data_type + if data_type == "string": return "" - elif self.data_type == "integer": + elif data_type == "integer": return 0 - elif self.data_type == "float": + elif data_type == "float": return 0.0 - elif self.data_type == "boolean": + elif data_type == "boolean": return False - elif self.data_type == "enum": + elif data_type == "enum": return "0" - elif self.data_type == "file": + elif data_type == "file": return "" + else: + assert_never(data_type) - def get_value_name(self, display_only=False): - if self.data_type == "string": + def get_value_name(self, display_only: bool = False) -> str: + data_type = self.data_type + if data_type == "string": return "string_value" - elif self.data_type == "boolean": + elif data_type == "boolean": return "bool_value" - elif self.data_type == "integer": + elif data_type == "integer": return "int_value" - elif self.data_type == "float": + elif data_type == "float": if display_only and self.special_type == "LENGTH": return "length_value" return "float_value" - elif self.data_type == "enum": + elif data_type == "enum": return "enum_value" - elif self.data_type == "file": + elif data_type == "file": return "filepath_value" + else: + assert_never(data_type) - def set_value(self, value): + def set_value(self, value: Union[Any, str, float, bool, int]) -> None: if isinstance(value, str): self.data_type = "string" elif isinstance(value, float): @@ -490,6 +508,7 @@ class BIMMeshProperties(PropertyGroup): subshape_type: StringProperty(name="Subshape Type") ifc_definition: StringProperty(name="IFC Definition") ifc_parameters: CollectionProperty(name="IFC Parameters", type=IfcParameter) + item_attributes: CollectionProperty(name="Item Attributes", type=Attribute) material_checksum: StringProperty(name="Material Checksum", default="[]") mesh_checksum: StringProperty(name="Mesh Checksum", default="") replaced_mesh: PointerProperty(type=bpy.types.Mesh, description="Original mesh to revert section cutaway") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index d96b3e4762..1b5b2fef91 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -191,14 +191,26 @@ class BIM_UL_topics(bpy.types.UIList): class BIM_ADDON_preferences(bpy.types.AddonPreferences): bl_idname = tool.Blender.get_blender_addon_package_name() - svg2pdf_command: StringProperty(name="SVG to PDF Command", description='E.g. [["inkscape", "svg", "-o", pdf]]') + svg2pdf_command: StringProperty( + name="SVG to PDF Command", + description='print sheet to pdf together with svg. Leave blank svg is just created, E.g. [["path to application eg. /Applications/Inkscape.app/Contents/MacOS/inkscape", "svg", "-o", "pdf"]]', + ) svg2dxf_command: StringProperty( name="SVG to DXF Command", description='E.g. [["inkscape", "svg", "-o", "eps"], ["pstoedit", "-dt", "-f", "dxf:-polyaslines -mm", "eps", "dxf", "-psarg", "-dNOSAFER"]]', ) - svg_command: StringProperty(name="SVG Command", description='E.g. [["firefox", "path"]]') - layout_svg_command: StringProperty(name="Layout SVG Command", description='E.g. [["firefox", "path"]]') - pdf_command: StringProperty(name="PDF Command", description='E.g. [["firefox", "path"]]') + svg_command: StringProperty( + name="SVG Command", + description='Software to open generated drawing and sheets. Leave blank system default for .svg is used E.g. [["path to application eg. /Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "path"]]', + ) + layout_svg_command: StringProperty( + name="Layout SVG Command", + description='Software to open layouts, before generated. Leave blank system default for .svg is used E.g. [["path to application eg. /Applications/Inkscape.app/Contents/MacOS/inkscape", "path"]]', + ) + pdf_command: StringProperty( + name="PDF Command", + description='Software to open .pdf, leave blank uses system default. E.g. [["path to application eg. /Applications/Inkscape.app/Contents/MacOS/inkscape", "path"]]', + ) spreadsheet_command: StringProperty(name="Spreadsheet Command", description='E.g. [["libreoffice", "path"]]') should_hide_empty_props: BoolProperty(name="Hide Empty Properties", default=True) should_setup_workspace: BoolProperty(name="Setup Workspace Layout for BIM", default=True) @@ -209,7 +221,6 @@ 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) - lock_grids_on_import: BoolProperty(name="Lock Grids By Default", default=True) spatial_elements_unselectable: BoolProperty(name="Make Spatial Elements Unselectable By Default", default=True) decorations_colour: bpy.props.FloatVectorProperty( name="Decorations Colour", subtype="COLOR", default=(1, 1, 1, 1), min=0.0, max=1.0, size=4 @@ -295,8 +306,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): row = layout.row() row.prop(self, "should_play_chaching_sound") row = layout.row() - row.prop(self, "lock_grids_on_import") - row = layout.row() row.prop(self, "spatial_elements_unselectable") row = layout.row() @@ -421,34 +430,64 @@ class BIM_PT_tabs(Panel): row = self.layout.row(align=True) row.prop(aprops, "tab", text="") - - if bonsai.REINSTALLED_BBIM_VERSION: - box = self.layout.box() - box.alert = True - - box.label(text="Bonsai requires Blender to restart.", icon="ERROR") - box.label(text="Bonsai was reinstalled in the current session:") - box.label(text=f"{bonsai.FIRST_INSTALLED_BBIM_VERSION} -> {bonsai.REINSTALLED_BBIM_VERSION}") - box.label(text="Please restart Blender to avoid potential issues.") - box.operator("bim.restart_blender", text="Restart Blender", icon="BLENDER") - - if bonsai.last_error: - box = self.layout.box() - box.alert = True - row = box.row(align=True) - row.label(text="Bonsai experienced an error :(", icon="ERROR") - row.operator("bim.close_error", text="", icon="CANCEL") - if platform.system() == "Windows": - box.operator("wm.console_toggle", text="View the console for full logs.", icon="CONSOLE") - else: - box.label(text="View the console for full logs.", icon="CONSOLE") - box.operator("bim.copy_debug_information", text="Copy Error Message To Clipboard") - op = box.operator("bim.open_uri", text="How Can I Fix This?") - op.uri = "https://docs.bonsaibim.org/users/troubleshooting.html" - except: pass # Prior to load_post, we may not have any area properties setup + if bonsai.REINSTALLED_BBIM_VERSION: + box = self.layout.box() + box.alert = True + box.label(text="Bonsai requires Blender to restart.", icon="ERROR") + box.label(text="Bonsai was reinstalled in the current session:") + box.label(text=f"{bonsai.FIRST_INSTALLED_BBIM_VERSION} -> {bonsai.REINSTALLED_BBIM_VERSION}") + box.operator("bim.restart_blender", text="Restart Blender", icon="BLENDER") + + if bonsai.last_error: + box = self.layout.box() + box.alert = True + row = box.row(align=True) + row.label(text="Bonsai experienced an error :(", icon="ERROR") + row.operator("bim.close_error", text="", icon="CANCEL") + if platform.system() == "Windows": + box.operator("wm.console_toggle", text="View the console for full logs.", icon="CONSOLE") + else: + box.label(text="View the console for full logs.", icon="CONSOLE") + box.operator("bim.copy_debug_information", text="Copy Error Message To Clipboard") + op = box.operator("bim.open_uri", text="How Can I Fix This?") + op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html" + + if not tool.Ifc.get(): + return + + props = context.scene.BIMProperties + if props.has_blend_warning: + box = self.layout.box() + box.alert = True + row = box.row(align=True) + row.label(text="Your model may be outdated", icon="ERROR") + op = row.operator("bim.open_uri", text="", icon="QUESTION") + 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"): + pass + elif context.mode.startswith("EDIT") and context.scene.BIMGeometryProperties.mode == "EDIT": + pass + else: + box = self.layout.box() + box.alert = True + row = box.row(align=True) + row.label(text="Geometry changes will be lost", icon="ERROR") + op = row.operator("bim.open_uri", text="", icon="QUESTION") + op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#incompatible-blender-features" + + if (o := context.active_object) and [round(x, 4) for x in list(o.matrix_world.to_scale())] != [1, 1, 1]: + box = self.layout.box() + box.alert = True + row = box.row(align=True) + row.label(text="Object scaling will be lost", icon="ERROR") + op = row.operator("bim.open_uri", text="", icon="QUESTION") + op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#incompatible-blender-features" + def draw_tab_entry(self, row, icon, tab_name, enabled=True, highlight=False): tab_entry = row.row(align=True) tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon, depress=True).tab = tab_name @@ -499,8 +538,8 @@ class BIM_PT_tab_project_info(Panel): pass -class BIM_PT_tab_spatial_decomposition(Panel): - bl_label = "Spatial Decomposition" +class BIM_PT_tab_spatial(Panel): + bl_label = "Spatial" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "scene" @@ -1053,7 +1092,7 @@ def draw_custom_context_menu(self, context): prop = context.button_pointer prop_name = context.button_prop.identifier prop_value = getattr(prop, prop_name, None) - if prop_value is None: + if not isinstance(prop_value, str): return version = tool.Ifc.get_schema() layout = self.layout diff --git a/src/bonsai/bonsai/core/bsdd.py b/src/bonsai/bonsai/core/bsdd.py index 7091beaf17..5f8c287ae7 100644 --- a/src/bonsai/bonsai/core/bsdd.py +++ b/src/bonsai/bonsai/core/bsdd.py @@ -47,7 +47,7 @@ def load_bsdd(client: bsdd.Client, bsdd: tool.Bsdd) -> None: def search_class(keyword: str, client: bsdd.Client, bsdd: tool.Bsdd) -> int: bsdd.clear_classes() - related_entities = bsdd.get_related_ifc_entities(keyword) + related_entities = bsdd.get_related_ifc_entities() active_dictionary_uri = bsdd.get_active_dictionary_uri() classes = bsdd.search_class(client, keyword, [active_dictionary_uri], related_entities) bsdd.create_classes(classes) diff --git a/src/bonsai/bonsai/core/cost.py b/src/bonsai/bonsai/core/cost.py index 30240792a1..b465ba0f8c 100644 --- a/src/bonsai/bonsai/core/cost.py +++ b/src/bonsai/bonsai/core/cost.py @@ -41,8 +41,8 @@ def disable_editing_cost_schedule(cost: tool.Cost) -> None: def remove_cost_schedule(ifc: tool.Ifc, cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> None: - ifc.run("cost.remove_cost_schedule", cost_schedule=cost_schedule) cost.remove_stored_schedule_columns(cost_schedule) + ifc.run("cost.remove_cost_schedule", cost_schedule=cost_schedule) def enable_editing_cost_schedule_attributes(cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> None: @@ -398,3 +398,7 @@ def add_currency(ifc: tool.Ifc, cost: tool.Cost) -> ifcopenshell.entity_instance ifc.run("unit.edit_monetary_unit", unit=unit, attributes=attributes) ifc.run("unit.assign_unit", units=[unit]) return unit + + +def generate_cost_schedule_browser(cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> bpy.types.Panel: + return cost.generate_cost_schedule_browser(cost_schedule) diff --git a/src/bonsai/bonsai/core/debug.py b/src/bonsai/bonsai/core/debug.py index 94f78d4a9f..ccf569798c 100644 --- a/src/bonsai/bonsai/core/debug.py +++ b/src/bonsai/bonsai/core/debug.py @@ -17,15 +17,24 @@ # along with Bonsai. If not, see . -def parse_express(debug, filename): +from __future__ import annotations +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + import bpy + import ifcopenshell + import bonsai.tool as tool + + +def parse_express(debug: tool.Debug, filename: str) -> None: debug.add_schema_identifier(debug.load_express(filename)) -def purge_hdf5_cache(debug): +def purge_hdf5_cache(debug: tool.Debug) -> None: debug.purge_hdf5_cache() -def purge_unused_elements(ifc, debug, ifc_class): +def purge_unused_elements(ifc, debug: tool.Debug, ifc_class: str) -> int: ifc_file = ifc.get() unused_elements = [i for i in ifc_file.by_type(ifc_class) if ifc_file.get_total_inverses(i) == 0] unused_elements_amount = len(unused_elements) diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 4afb45c4e9..57ab21dbe7 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -76,7 +76,7 @@ def disable_editing_sheets(drawing: tool.Drawing) -> None: drawing.disable_editing_sheets() -def add_sheet(ifc: tool.Ifc, drawing, titleblock: ifcopenshell.entity_instance) -> None: +def add_sheet(ifc: tool.Ifc, drawing: tool.Drawing, titleblock: ifcopenshell.entity_instance) -> None: sheet = ifc.run("document.add_information") layout = ifc.run("document.add_reference", information=sheet) titleblock_reference = ifc.run("document.add_reference", information=sheet) @@ -127,7 +127,9 @@ def remove_sheet(ifc: tool.Ifc, drawing: tool.Drawing, sheet: ifcopenshell.entit drawing.import_sheets() -def rename_sheet(ifc: tool.Ifc, drawing, sheet: ifcopenshell.entity_instance, identification: str, name: str) -> None: +def rename_sheet( + ifc: tool.Ifc, drawing: tool.Drawing, sheet: ifcopenshell.entity_instance, identification: str, name: str +) -> None: if ifc.get_schema() == "IFC2X3": attributes = {"DocumentId": identification, "Name": name} else: diff --git a/src/bonsai/bonsai/core/geometry.py b/src/bonsai/bonsai/core/geometry.py index 73625af636..d3206a8002 100644 --- a/src/bonsai/bonsai/core/geometry.py +++ b/src/bonsai/bonsai/core/geometry.py @@ -64,7 +64,7 @@ def add_representation( context=context, blender_object=obj, geometry=data, - coordinate_offset=geometry.get_cartesian_point_coordinate_offset(obj), + coordinate_offset=geometry.get_cartesian_point_offset(obj), total_items=geometry.get_total_representation_items(obj), should_force_faceted_brep=geometry.should_force_faceted_brep(), should_force_triangulation=geometry.should_force_triangulation(), @@ -121,43 +121,10 @@ def switch_representation( if not geometry.does_representation_id_exist(representation_id): return - entity = ifc.get_entity(obj) - current_obj_data = geometry.get_object_data(obj) - - if not current_obj_data and geometry.is_text_literal(representation): + if not geometry.get_object_data(obj) and geometry.is_text_literal(representation): return - use_immediate_repr = geometry.should_use_immediate_representation(entity, apply_openings) - if use_immediate_repr: - # if it has openings make sure to switch to element's mapped representation - representation = geometry.unresolve_type_representation(representation, entity) - else: - # doesn't resolve mapped representations in case if it's going to have openings - # otherwise we would also add openings to the type and other occurences mesh data - representation = geometry.resolve_mapped_representation(representation) - - old_repr_data = geometry.get_representation_data(representation) - if should_reload or not old_repr_data: - new_repr_data = geometry.import_representation(obj, representation, apply_openings=apply_openings) - geometry.rename_object(new_repr_data, geometry.get_representation_name(representation)) - geometry.link(representation, new_repr_data) - else: - new_repr_data = old_repr_data - - geometry.change_object_data(obj, new_repr_data, is_global=is_global and not use_immediate_repr) - geometry.record_object_materials(obj) - - # we assume that all the occurences and the type have the same representation context active - # so geometry.delete_data cannot remove the data that's still used by some other object - if should_reload and old_repr_data: - # if current object was using some temporary mesh (like during profile edit mode) instead of `old_repr_data` - # then `change_object_data` won't switch the mesh for all the occurences and we need to do it explicitly - if current_obj_data != old_repr_data and geometry.has_data_users(old_repr_data): - geometry.replace_object_data_globally(old_repr_data, new_repr_data) - geometry.delete_data(old_repr_data) - - geometry.clear_modifiers(obj) - geometry.clear_cache(entity) + geometry.reimport_element_representations(obj, representation, apply_openings=apply_openings) def get_representation_ifc_parameters( diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 9ca1469c79..17dde89fad 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -1,16 +1,44 @@ -def create_repo(ifcgit, ifc): +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 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 . + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + import bpy + import ifcopenshell + import bonsai.tool as tool + import git + + +def create_repo(ifcgit: tool.IfcGit, ifc: tool.Ifc) -> None: path_ifc = ifc.get_path() path_dir = ifcgit.get_path_dir(path_ifc) ifcgit.init_repo(path_dir) -def add_file(ifcgit, ifc): +def add_file(ifcgit: tool.IfcGit, ifc: tool.Ifc) -> None: path_ifc = ifc.get_path() repo = ifcgit.repo_from_path(path_ifc) ifcgit.add_file_to_repo(repo, path_ifc) -def clone_repo(ifcgit, remote_url, local_folder, operator): +def clone_repo(ifcgit: tool.IfcGit, remote_url: str, local_folder: str, operator: bpy.types.Operator) -> None: repo = ifcgit.clone_repo(remote_url, local_folder) if not repo: operator.report({"ERROR"}, "Clone failed") @@ -19,14 +47,14 @@ def clone_repo(ifcgit, remote_url, local_folder, operator): ifcgit.load_anyifc(repo) -def discard_uncommitted(ifcgit, ifc): +def discard_uncommitted(ifcgit: tool.IfcGit, ifc: tool.Ifc) -> None: path_ifc = ifc.get_path() # NOTE this is calling the git binary in a subprocess ifcgit.git_checkout(path_ifc) ifcgit.load_project(path_ifc) -def commit_changes(ifcgit, ifc, repo): +def commit_changes(ifcgit: tool.IfcGit, ifc: tool.Ifc, repo: git.Repo) -> None: """Commit and create new branches as required""" path_ifc = ifc.get_path() @@ -38,34 +66,34 @@ def commit_changes(ifcgit, ifc, repo): ifcgit.git_commit(path_ifc) -def add_tag(ifcgit, repo): +def add_tag(ifcgit: tool.IfcGit, repo: git.Repo) -> None: ifcgit.add_tag(repo) -def delete_tag(ifcgit, repo, tag_name): +def delete_tag(ifcgit: tool.IfcGit, repo: git.Repo, tag_name: git.TagReference) -> None: ifcgit.delete_tag(repo, tag_name) -def add_remote(ifcgit, repo): +def add_remote(ifcgit: tool.IfcGit, repo: git.Repo) -> None: ifcgit.add_remote(repo) -def delete_remote(ifcgit, repo): +def delete_remote(ifcgit: tool.IfcGit, repo: git.Repo) -> None: ifcgit.delete_remote(repo) -def push(ifcgit, repo, remote_name, operator): +def push(ifcgit: tool.IfcGit, repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None: error_message = ifcgit.push(repo, remote_name, repo.active_branch.name) if error_message: operator.report({"ERROR"}, error_message) -def refresh_revision_list(ifcgit, repo, ifc): +def refresh_revision_list(ifcgit: tool.IfcGit, repo: git.Repo, ifc: tool.Ifc) -> None: if repo.heads: ifcgit.refresh_revision_list(ifc.get_path()) -def colourise_revision(ifcgit): +def colourise_revision(ifcgit: tool.IfcGit) -> None: step_ids = ifcgit.get_revisions_step_ids() if not step_ids: @@ -75,28 +103,29 @@ def colourise_revision(ifcgit): ifcgit.colourise(final_step_ids) -def colourise_uncommitted(ifcgit, ifc, repo): +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) -def switch_revision(ifcgit, ifc): +def switch_revision(ifcgit: tool.IfcGit, ifc: tool.Ifc) -> None: # FIXME bad things happen when switching to a revision that predates current project path_ifc = ifc.get_path() ifcgit.switch_to_revision_item() ifcgit.load_project(path_ifc) ifcgit.refresh_revision_list(path_ifc) + ifcgit.decolourise() -def merge_branch(ifcgit, ifc, operator): +def merge_branch(ifcgit: tool.IfcGit, ifc: tool.Ifc, operator: bpy.types.Operator) -> None: path_ifc = ifc.get_path() ifcgit.config_ifcmerge() ifcgit.execute_merge(path_ifc, operator) -def entity_log(ifcgit, ifc, step_id, operator): +def entity_log(ifcgit: tool.IfcGit, ifc: tool.Ifc, step_id: int, operator: bpy.types.Operator) -> None: path_ifc = ifc.get_path() log_text = ifcgit.entity_log(path_ifc, step_id) # ERROR is only way to display a multi-line message diff --git a/src/bonsai/bonsai/core/material.py b/src/bonsai/bonsai/core/material.py index 7c5523bd1e..fcec980206 100644 --- a/src/bonsai/bonsai/core/material.py +++ b/src/bonsai/bonsai/core/material.py @@ -40,6 +40,7 @@ def add_material( def add_material_set(ifc: tool.Ifc, material: tool.Material, set_type: str) -> ifcopenshell.entity_instance: ifc_material = ifc.run("material.add_material_set", name="Unnamed", set_type=set_type) + material.ensure_new_material_set_is_valid(ifc_material) if material.is_editing_materials(): material.import_material_definitions(material.get_active_material_type()) return ifc_material diff --git a/src/bonsai/bonsai/core/profile.py b/src/bonsai/bonsai/core/profile.py index d8cf537345..a0ee00b6de 100644 --- a/src/bonsai/bonsai/core/profile.py +++ b/src/bonsai/bonsai/core/profile.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: def purge_unused_profiles(ifc: tool.Ifc, profile: tool.Profile) -> int: - """Purge profiles that have no invserses. + """Purge profiles that have no inverses. :return: Number of removed profiles. """ diff --git a/src/bonsai/bonsai/core/project.py b/src/bonsai/bonsai/core/project.py index cf09008ef2..5497d93c58 100644 --- a/src/bonsai/bonsai/core/project.py +++ b/src/bonsai/bonsai/core/project.py @@ -87,6 +87,9 @@ def create_project( project.run_context_add_context( context_type="Plan", context_identifier="Annotation", target_view="PLAN_VIEW", parent=plan ) + project.run_context_add_context( + context_type="Plan", context_identifier="Annotation", target_view="REFLECTED_PLAN_VIEW", parent=plan + ) project.run_root_assign_class(obj=site, ifc_class="IfcSite", context=body) project.run_root_assign_class(obj=building, ifc_class="IfcBuilding", context=body) @@ -107,4 +110,5 @@ def create_project( project.load_default_thumbnails() project.set_default_context() project.set_default_modeling_dimensions() + project.run_root_reload_grid_decorator() georeference.set_model_origin() diff --git a/src/bonsai/bonsai/core/pset.py b/src/bonsai/bonsai/core/pset.py index c3ffd65046..c96e5177ff 100644 --- a/src/bonsai/bonsai/core/pset.py +++ b/src/bonsai/bonsai/core/pset.py @@ -89,6 +89,19 @@ def enable_pset_editing( pset_tool.enable_proposed_pset(props, pset_name, pset_type, has_template) -def add_proposed_prop(pset: tool.Pset, obj_name: str, obj_type: tool.Ifc.OBJECT_TYPE, name: str, value: Any) -> None: +def add_proposed_prop( + pset: tool.Pset, obj_name: str, obj_type: tool.Ifc.OBJECT_TYPE, name: str, value: Any +) -> Union[None, str]: props = pset.get_pset_props(obj_name, obj_type) - pset.add_proposed_property(name, pset.cast_string_to_primitive(value), props) + res = pset.add_proposed_property(name, pset.cast_string_to_primitive(value), props) + return res + + +def unshare_pset( + ifc: tool.Ifc, pset_tool: tool.Pset, obj_type: tool.Ifc.OBJECT_TYPE, obj_name: str, pset_id: int +) -> None: + elements: list[ifcopenshell.entity_instance] + pset = ifc.get_entity_by_id(pset_id) + assert pset + elements = pset_tool.get_selected_pset_elements(obj_name, obj_type, pset) + ifc.run("pset.unshare_pset", products=elements, pset=pset) diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 4bad423e30..bd1ba68d3a 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -141,17 +141,13 @@ def delete_container( spatial.import_spatial_decomposition() -def select_decomposed_elements( - spatial: tool.Spatial, - container: ifcopenshell.entity_instance, - ifc_class: str | None = None, - relating_type: ifcopenshell.entity_instance | None = None, - is_untyped: bool = False, - element_filter: str | None = None, -) -> None: - elements = spatial.get_decomposed_elements(container) - elements = spatial.filter_elements(elements, ifc_class, relating_type, is_untyped, element_filter) - spatial.select_products(elements) +def toggle_container_element(spatial: tool.Spatial, element_index: int, is_recursive: bool) -> None: + spatial.toggle_container_element(element_index, is_recursive=is_recursive) + spatial.load_contained_elements() + + +def select_decomposed_element(ifc: tool.Ifc, spatial: tool.Spatial, element: ifcopenshell.entity_instance) -> None: + spatial.set_active_object(ifc.get_object(element)) def generate_space(ifc: tool.Ifc, model: tool.Model, root: tool.Root, spatial: tool.Spatial, type: tool.Type) -> None: diff --git a/src/bonsai/bonsai/core/style.py b/src/bonsai/bonsai/core/style.py index 347efb4fe0..9d4cd5c9fe 100644 --- a/src/bonsai/bonsai/core/style.py +++ b/src/bonsai/bonsai/core/style.py @@ -55,7 +55,15 @@ def update_external_style( ifc.run("style.edit_surface_style", style=external_style, attributes=attributes) -def remove_style(ifc: tool.Ifc, style_tool: tool.Style, style: ifcopenshell.entity_instance) -> None: +def remove_style( + ifc: tool.Ifc, style_tool: tool.Style, style: ifcopenshell.entity_instance, reload_styles_ui: bool = False +) -> None: + """Remove IfcPresentationStyle and associated Blender material. + + :param reload_styles_ui: Whether to reload Styles UI after removal. + Useful to disable if you plan to remove many styles and want to + avoid unnecessary reloads. + """ obj = ifc.get_object(style) # Get style_type before removing object as later StylesData might fail to load # due object not yet removed completely. @@ -63,7 +71,7 @@ def remove_style(ifc: tool.Ifc, style_tool: tool.Style, style: ifcopenshell.enti ifc.unlink(element=style) ifc.run("style.remove_style", style=style) style_tool.delete_object(obj) - if style_tool.is_editing_styles(): + if reload_styles_ui and style_tool.is_editing_styles(): style_tool.import_presentation_styles(style_type) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index f8ddc82421..4b3f3f7fe7 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -390,7 +390,7 @@ class Geometry: def delete_ifc_object(cls, obj): pass def does_representation_id_exist(cls, representation_id): pass def duplicate_object_data(cls, obj): pass - def get_cartesian_point_coordinate_offset(cls, obj): pass + def get_cartesian_point_offset(cls, obj): pass def get_element_type(cls, element): pass def get_elements_of_type(cls, type): pass def get_ifc_representation_class(cls, element, representation): pass @@ -465,6 +465,7 @@ class Georeference: class Ifc: def get(cls): pass def get_entity(cls, obj): pass + def get_entity_by_id(cls, element_id): pass def get_object(cls, entity): pass def get_path(cls): pass def get_schema(cls): pass @@ -510,6 +511,7 @@ class Material: def enable_editing_materials(cls): pass def ensure_material_assigned(cls, elements, material_type, material): pass def ensure_material_unassigned(cls, elements): pass + def ensure_new_material_set_is_valid(cls, material): pass def get_active_material_item(cls): pass def get_active_material_type(cls): pass def get_default_material(cls): pass @@ -579,6 +581,16 @@ class Patch: def run_migrate_patch(cls, infile, outfile, schema): pass +@interface +class Polyline: + def create_input_ui(cls, init_z=False, init_area=False): pass + def create_tool_state(cls): pass + def calculate_distance_and_angle(cls, context, input_ui, tool_state): pass + def calculate_area(cls, context, input_ui): pass + def calculate_x_y_and_z(cls, context, input_ui, tool_state): pass + def validate_input(cls, input_number, input_type): pass + + @interface class Owner: def add_address_attribute(cls, name): pass @@ -650,6 +662,7 @@ class Pset: def get_prop_template_primitive_type(cls, prop_template): pass def get_pset_name(cls, obj, obj_type, pset_type): pass def get_pset_template(cls, name): pass + def get_selected_pset_elements(cls, obj_name, obj_type, pset): pass def import_enumerated_value_from_template(cls, prop_template, data, props): pass def import_pset_from_existing(cls, pset, props): pass def import_pset_from_template(cls, pset_template, pset, props): pass @@ -665,10 +678,12 @@ class Qto: def get_rounded_value(cls, new_quantity): pass def set_qto_result(cls, result): pass + @interface class Raycast: pass + @interface class Resource: def clear_productivity_data(cls, props): pass @@ -942,10 +957,12 @@ class Spatial: class Covering: def get_z_from_ceiling_height(cls): pass + @interface class Snap: pass + @interface class Structural: def disable_editing_structural_analysis_model(cls): pass diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index a7d28cab44..16a64716e4 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -44,6 +44,7 @@ from bonsai.tool.model import Model from bonsai.tool.nest import Nest from bonsai.tool.owner import Owner from bonsai.tool.patch import Patch +from bonsai.tool.polyline import Polyline from bonsai.tool.project import Project from bonsai.tool.profile import Profile from bonsai.tool.pset import Pset diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index cbec92a46f..9aeeda1c78 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -707,7 +707,7 @@ class Blender(bonsai.core.tool.Blender): if not tool.Ifc.get(): return False element = tool.Ifc.get_entity(obj) - return element and element.is_a() in classes + return bool(element) and element.is_a() in classes @classmethod def get_object_from_guid(cls, guid: str) -> Union[IFC_CONNECTED_TYPE, None]: @@ -803,7 +803,7 @@ class Blender(bonsai.core.tool.Blender): return collections_mapping @classmethod - def is_editable(cls, obj): + def is_editable(cls, obj: bpy.types.Object) -> bool: if obj.type not in cls.OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE: return False if not (element := tool.Ifc.get_entity(obj)): @@ -825,7 +825,7 @@ class Blender(bonsai.core.tool.Blender): @classmethod def is_eligible_for_stair_modifier(cls, obj): return tool.Blender.is_object_an_ifc_class( - obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType") + obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType") ) @classmethod @@ -1155,3 +1155,12 @@ class Blender(bonsai.core.tool.Blender): pass return sun_position + + @classmethod + def scale_font_size(cls, size): + default_dpi = 72 + default_pixel_size = 1.0 + default_scale = default_dpi * default_pixel_size + system = bpy.context.preferences.system + system_scale = system.dpi * system.pixel_size + return (system_scale / default_scale) * size diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index 12ac7e12f9..b82ed6c47f 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -37,9 +37,10 @@ class Bsdd(bonsai.core.tool.Bsdd): new2.enum_items = json.dumps(data["possible_values"]) new2.data_type = "enum" else: - new2.data_type = data_type_map[data["data_type"]] + new2.data_type = data_type_map.get(data["data_type"], "string") new2.description = data["description"] new2.ifc_class = data["ifc_class"] + new2.metadata = data["dictionary"] @classmethod def create_classes(cls, class_dict: list[bsdd.ClassSearchResponseClassContractV1]) -> None: @@ -96,8 +97,7 @@ class Bsdd(bonsai.core.tool.Bsdd): psets = {} for prop in properties: - if prop.get("propertyDictionaryName") != "IFC": - continue + prop_dictionary = prop.get("propertyDictionaryName") or "" pset = prop.get("propertySet", None) if not pset: continue @@ -111,21 +111,22 @@ class Bsdd(bonsai.core.tool.Bsdd): possible_values = [v["value"] for v in possible_values] description = prop.get("description", "") - - psets[pset][prop["name"]] = { - "data_type": prop["dataType"], + # 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] = { + "data_type": prop.get("dataType"), "possible_values": possible_values, "description": description, "ifc_class": ifc_class, + "dictionary": prop_dictionary, } return psets @classmethod - def get_related_ifc_entities(cls, keyword: str) -> list[str]: + def get_related_ifc_entities(cls) -> list[str]: active_object = bpy.context.active_object related_ifc_entities = [] - if len(keyword) < 3: - return [] if cls.should_filter_ifc_class() and active_object: element = tool.Ifc.get_entity(active_object) if element: @@ -139,11 +140,16 @@ class Bsdd(bonsai.core.tool.Bsdd): keyword: str, dictionary_uris: Union[list[str], None], related_ifc_entities: Union[list[str], None], + offset: int = 0, ) -> list[bsdd.ClassSearchResponseClassContractV1]: response = client.search_class( - keyword, dictionary_uris=dictionary_uris, related_ifc_entities=related_ifc_entities + keyword, dictionary_uris=dictionary_uris, related_ifc_entities=related_ifc_entities, offset=offset ) - return response.get("classes", []) + classes = response.get("classes", []) + # If count is 100, it might be hitting the limit. + if response["count"] == 100: + classes += cls.search_class(client, keyword, dictionary_uris, related_ifc_entities, offset=offset + 100) + return classes @classmethod def set_active_bsdd(cls, name: str, uri: str) -> None: diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 6693509d90..3ba3aaec59 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -85,34 +85,45 @@ class Cad: return math.degrees(a) if degrees else a @classmethod - def angle_3_vectors(cls, v1, v2, v3, degrees=False): + def angle_3_vectors(cls, v1, v2, v3, new_angle=None, degrees=False): """ > takes 3 vectors. The order matters, v2 is the center point. - < returns the potentially signed angle as degrees or radians + < returns the signed angle as degrees or radians + < if a new angle is provided, return the rotation vector """ d1 = v1 - v2 - d2 = v2 - v3 + d2 = v3 - v2 - axis = d1.cross(d2) - axis.normalize() - axis = Vector((abs(axis.x), abs(axis.y), abs(axis.z))) + d1.normalize() + d2.normalize() - rotation_axis = d1.cross(d2) + axis = d1.cross(d2).normalized() - # Calculate the unsigned angle between the "from" and "to" vectors - a = d1.angle(d2) + # Calculate the unsigned angle between the "d1" and "d2" vectors + try: + a = d1.angle(d2) + except: + a = 0 # Determine the sign of the angle based on the provided axis - if degrees: - a = math.degrees(a) - - parameter = rotation_axis.dot(axis) - - sign = 1 if parameter <= 0 else -1 - - return a * sign + # If new_angle, determine the direction of the rotation + 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)) + ) + if new_angle is not None: + rot_mat = Matrix.Rotation(new_angle, 3, axis) + rot_vector = (d1 @ rot_mat) if parameter else (rot_mat @ d1) + return rot_vector else: - return a + sign = -1 if parameter else 1 + + if degrees: + a = math.degrees(a) + return a * sign + else: + return a @classmethod def is_x(cls, value: float, x: float, tolerance: float | None = None) -> bool: @@ -142,6 +153,14 @@ class Cad: def are_vectors_equal(cls, v1: Vector, v2: Vector, tolerance: float | None = None) -> bool: return cls.is_x((v2 - v1).length, 0, tolerance) + @classmethod + def intersect_edge_plane(cls, v1, v2, plane_co, plane_no): + """ + > takes an edges as two vector, and a plane as origin point and normal + < return the intersection point or None + """ + return geometry.intersect_line_plane(v1, v2, plane_co, plane_no) + @classmethod def intersect_edges(cls, edge1, edge2): """ @@ -163,6 +182,44 @@ class Cad: return r1.to_2d() if r1 else r1, r2.to_2d() if r2 else r2 return results + @classmethod + def intersect_edges_v2(cls, edge1, edge2): + """ + Calculate the closest points on two line segments. + Note: This function doesn't use intersect_line_line + + > edge1: tuple of two vectors (v1, v2) representing the first segment + > edge2: tuple of two vectors (v3, v4) representing the second segment + < returns: tuple of two vectors (C1, C2) or (None, None) if lines are parallel + """ + # This function seems to work better then intersect_line_line + # in orthogonal view + # https://en.wikipedia.org/wiki/Skew_lines#Nearest_points + + # Starting and ending points + P1, P1_end = edge1 + P2, P2_end = edge2 + + # Directions + d1 = (P1_end - P1).normalized() + d2 = (P2_end - P2).normalized() + + n = d1.cross(d2) + + # if n is zero, lines are parallel + if n.length == 0: + return None, None + + n2 = d2.cross(n) + + C1 = P1 + ((P2 - P1).dot(n2) / (d1.dot(n2))) * d1 + + n1 = d1.cross(n) + + C2 = P2 + ((P1 - P2).dot(n1) / (d2.dot(n1))) * d2 + + return C1, C2 + @classmethod def get_intersection(cls, edge1, edge2): """ @@ -569,3 +626,147 @@ class Cad: @classmethod def get_basis_vector(cls, object, axis_i): return object.matrix_world.col[axis_i].normalized().to_3d() + + @classmethod + def offset_edges(cls, bm, distance, mw=Matrix.Identity(4), wp=Matrix.Identity(4)): + + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + + edges = [e for e in bm.edges] + + if len(edges) < 1: + return + + verts = set() + [verts.update(e.verts) for e in edges] + + rotation = Matrix.Rotation(math.pi / 2, 2, "Z") + rotation_i = Matrix.Rotation(-math.pi / 2, 2, "Z") + + # Create loops from edges + loop_edges = set(edges) + loops = [] + while loop_edges: + edge = loop_edges.pop() + loop = [edge] + has_found_connected_edge = True + while has_found_connected_edge: + has_found_connected_edge = False + for edge in loop_edges.copy(): + edge_verts = set(edge.verts) + if edge_verts & set(loop[0].verts): + loop.insert(0, edge) + loop_edges.remove(edge) + has_found_connected_edge = True + elif edge_verts & set(loop[-1].verts): + loop.append(edge) + loop_edges.remove(edge) + has_found_connected_edge = True + loops.append(loop) + + for loop in loops: + all_verts = {v.index for e in loop for v in e.verts} + possible_v1s = [] + is_closed = True + for edge in loop: + # If we have an open loop, start at either end instead + v = edge.verts[0] + if len(v.link_edges) == 1: + possible_v1s.append(v) + is_closed = False + else: + for link_edge in v.link_edges: + if link_edge.other_vert(v).index not in all_verts: + possible_v1s.append(v) + is_closed = False + break + v = edge.verts[1] + if len(v.link_edges) == 1: + possible_v1s.append(v) + is_closed = False + else: + for link_edge in v.link_edges: + if link_edge.other_vert(v).index not in all_verts: + possible_v1s.append(v) + is_closed = False + break + + # Always start at the same vertex, so that when the operator is + # refreshed with new parameters the axes don't randomly get flipped. + if is_closed: + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + start = bm.verts[min(all_verts)] + else: + start = possible_v1s[0] if possible_v1s[0].index < possible_v1s[1].index else possible_v1s[1] + + v1 = start + + new_verts = [] + processed_verts = set() + # [v0] --> v1 --> v2 + while v1: + if v1.index in processed_verts: + break + + link_verts = [] + if len(v1.link_edges) == 1: + v = v1.link_edges[0].other_vert(v1) + link_verts.append(v) if v.index in all_verts else None + else: + v = v1.link_edges[0].other_vert(v1) + link_verts.append(v) if v.index in all_verts else None + v = v1.link_edges[1].other_vert(v1) + link_verts.append(v) if v.index in all_verts else None + + if len(link_verts) == 1: + v2 = link_verts[0] + v0 = None if v2.index not in processed_verts else v2 + v2 = None if v2.index in processed_verts else v2 + else: + v2 = link_verts[0] + v0 = link_verts[1] + if v2.index in processed_verts: + if is_closed and v0.index in processed_verts: + v3 = start + v0 = v0 if v2 == v3 else v2 + v2 = v3 + else: + v0, v2 = v2, v0 + + normals = [] + v1co = (wp.inverted() @ mw @ v1.co).to_2d() + if v2: + v2co = (wp.inverted() @ mw @ v2.co).to_2d() + direction = (v2co - v1co).normalized() + local_direction = (rotation @ direction).normalized() + normals.append(local_direction) + + if v0: + v0co = (wp.inverted() @ mw @ v0.co).to_2d() + direction = (v0co - v1co).normalized() + local_direction = (rotation_i @ direction).normalized() + normals.append(local_direction) + + if len(normals) == 2: + # https://stackoverflow.com/a/54042831/9627415 + new_normal = (normals[0].lerp(normals[1], 0.5)).normalized() + offset_length = distance / math.sqrt((1 + normals[0].dot(normals[1])) / 2) + offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d()) + new_vert = v1.co + offset + new_verts.append(bm.verts.new(new_vert)) + else: + normal = (normals[0] * distance).to_3d() + offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal) + new_vert = v1.co + offset + new_verts.append(bm.verts.new(new_vert)) + + processed_verts.add(v1.index) + + if v2 in processed_verts: + break + + v1 = v2 + + return new_verts diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index b5b163d967..e571e910cf 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -39,80 +39,88 @@ class Collector(bonsai.core.tool.Collector): users_collection.objects.unlink(obj) element = tool.Ifc.get_entity(obj) - assert element + + # Note that tool.Geometry.is_locked is only checked within the if + # statements for efficiency as it is a slow check. if element.is_a("IfcGridAxis"): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) element = (element.PartOfU or element.PartOfV or element.PartOfW)[0] + elif element.is_a("IfcGrid"): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) if element.is_a("IfcProject"): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) if collection := cls._create_own_collection(obj): - cls.link_to_collection_safe(obj, collection) - cls.link_to_collection_safe(collection, bpy.context.scene.collection) + cls.link_collection_object_safe(collection, obj) + cls.link_collection_child_safe(bpy.context.scene.collection, collection) elif element.is_a("IfcTypeProduct"): collection = cls._create_project_child_collection("IfcTypeProduct") - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) elif element.is_a("IfcOpeningElement"): collection = cls._create_project_child_collection("IfcOpeningElement") - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) elif element.is_a("IfcStructuralItem"): collection = cls._create_project_child_collection("IfcStructuralItem") - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) elif element.is_a("IfcLinearPositioningElement"): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) collection = cls._create_project_child_collection("IfcLinearPositioningElement") - collection.hide_viewport = False - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) elif element.is_a("IfcReferent"): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) collection = cls._create_project_child_collection("IfcReferent") - collection.hide_viewport = False - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) elif tool.Ifc.get_schema() == "IFC2X3" and element.is_a("IfcSpatialStructureElement"): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) if collection := cls._create_own_collection(obj): - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - cls.link_to_collection_safe(collection, project_obj.BIMObjectProperties.collection) + cls.link_collection_child_safe(project_obj.BIMObjectProperties.collection, collection) elif ( tool.Ifc.get_schema() != "IFC2X3" and element.is_a("IfcSpatialElement") and not element.is_a("IfcSpatialZone") ): + if tool.Geometry.is_locked(element): + tool.Geometry.lock_object(obj) if collection := cls._create_own_collection(obj): - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - cls.link_to_collection_safe(collection, project_obj.BIMObjectProperties.collection) + cls.link_collection_child_safe(project_obj.BIMObjectProperties.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) + elif element.is_a("IfcAnnotation") and (drawing_obj := cls.get_annotation_drawing_obj(element)): + cls.link_collection_object_safe(drawing_obj.BIMObjectProperties.collection, obj) elif container := ifcopenshell.util.element.get_container(element): container_obj = tool.Ifc.get_object(container) if not (collection := container_obj.BIMObjectProperties.collection): cls.assign(container_obj) collection = container_obj.BIMObjectProperties.collection - cls.link_to_collection_safe(obj, collection) - elif element.is_a("IfcAnnotation"): - if element.ObjectType == "DRAWING": - if collection := cls._create_own_collection(obj): - cls.link_to_collection_safe(obj, collection) - project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - cls.link_to_collection_safe(collection, project_obj.BIMObjectProperties.collection) - else: - for rel in element.HasAssignments or []: - if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": - for related_object in rel.RelatedObjects: - if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING": - drawing_obj = tool.Ifc.get_object(related_object) - if drawing_obj: - cls.link_to_collection_safe(obj, drawing_obj.BIMObjectProperties.collection) + cls.link_collection_object_safe(collection, obj) else: collection = cls._create_project_child_collection("Unsorted") - collection.hide_viewport = False - cls.link_to_collection_safe(obj, collection) + cls.link_collection_object_safe(collection, obj) @classmethod def _create_project_child_collection(cls, name: str) -> bpy.types.Collection: """get or create new collection inside project""" - collection = bpy.data.collections.get(name) - if not 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) - collection.hide_viewport = True + if collection := bpy.data.collections.get(name): + 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) + if layer_collection := tool.Blender.get_layer_collection(collection): + cls.set_layer_collection_visibility(layer_collection) return collection @classmethod @@ -127,23 +135,47 @@ class Collector(bonsai.core.tool.Collector): return collection @classmethod - def link_to_collection_safe( - cls, obj_or_col: Union[bpy.types.Object, bpy.types.Collection], collection: bpy.types.Collection - ) -> None: - """Link `obj_or_col` (an object or a collection) to the `collection` - if `obj_or_col` is not part of that collection already. + def get_annotation_drawing_obj(cls, element: ifcopenshell.entity_instance) -> bpy.types.Object | None: + for rel in element.HasAssignments or []: + if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": + for related_object in rel.RelatedObjects: + if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING": + return tool.Ifc.get_object(related_object) - Method is needed to avoid RuntimeErrors like below that occur if you link object/collection - to the collection directly and they are already part of that collection. - RuntimeError: Error: Object 'xxx' already in collection 'xxx'. - """ - # TODO: Maybe just catching RuntimeError is faster? - if isinstance(obj_or_col, bpy.types.Object): - if collection.objects.find(obj_or_col.name) != -1: - return - collection.objects.link(obj_or_col) - return + @classmethod + def link_collection_object_safe(cls, collection: bpy.types.Collection, obj: bpy.types.Object) -> None: + # Catching an exception is 10x faster than doing collection.objects.find + try: + collection.objects.link(obj) + except: + pass - if collection.children.find(obj_or_col.name) != -1: - return - collection.children.link(obj_or_col) + @classmethod + def link_collection_child_safe(cls, collection: bpy.types.Collection, child: bpy.types.Collection) -> None: + try: + collection.children.link(child) + except: + pass + + @classmethod + def set_layer_collection_visibility(cls, layer_collection): + name = layer_collection.collection.name + if name in ( + "IfcTypeProduct", + "IfcStructuralItem", + "Unsorted", + ): + layer_collection.hide_viewport = True + elif name.startswith("IfcAnnotation"): + layer_collection.hide_viewport = True + else: + layer_collection.hide_viewport = False + + @classmethod + def reset_default_visibility(cls): + project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) + project_collection = project.BIMObjectProperties.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: + cls.set_layer_collection_visibility(layer_collection2) diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 3ca735de29..cfc393b9ea 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -288,13 +288,21 @@ class Cost(bonsai.core.tool.Cost): unit = "" for quantities in ifcopenshell.util.element.get_psets(product, qtos_only=True).values(): for qto in tool.Ifc.get().by_id(quantities["id"]).Quantities or []: - for quantity in cost_item.CostQuantities: + for quantity in cost_item.CostQuantities or []: if quantity == qto: selected_quantitites.append(quantity) if not unit: unit = cls.get_quantity_unit_symbol(quantity) return selected_quantitites, unit + @classmethod + def get_assigned_product(cls, cost_item, quantity): + assigned_products = cls.get_cost_item_assignments(cost_item, filter_by_type="PRODUCT", is_deep=False) + for product in assigned_products: + assigned_quantities, _ = cls.get_assigned_quantities(cost_item, product) + if quantity in assigned_quantities: + return product + @classmethod def get_products(cls, related_object_type: RELATED_OBJECT_TYPE) -> list[ifcopenshell.entity_instance]: if related_object_type == "PRODUCT": @@ -659,11 +667,7 @@ class Cost(bonsai.core.tool.Cost): @classmethod def get_cost_schedule(cls, cost_item: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: - for rel in cost_item.HasAssignments or []: - if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcCostSchedule"): - return rel.RelatingControl - for rel in cost_item.Nests or []: - return cls.get_cost_schedule(rel.RelatingObject) + return ifcopenshell.util.cost.get_cost_schedule(cost_item) @classmethod def is_cost_schedule_active(cls, cost_schedule: ifcopenshell.entity_instance) -> bool: @@ -807,3 +811,79 @@ class Cost(bonsai.core.tool.Cost): return { "Currency": currency, } + + @classmethod + def create_cost_schedule_json(cls, cost_schedule: ifcopenshell.entity_instance) -> dict: + from bonsai.bim.module.cost.data import CostSchedulesData + + CostSchedulesData.load() + cost_items = CostSchedulesData.data["cost_items"] + data = [] + for rel in cost_schedule.Controls or []: + for cost_item in rel.RelatedObjects or []: + cls.create_cost_item_json(cost_item, cost_items, data) + return data + + @classmethod + def create_cost_item_json(cls, cost_item: ifcopenshell.entity_instance, cost_items: dict, data: list): + if cost_item.id() in cost_items.keys(): + cost_item_data = cost_items[cost_item.id()] + cost_item_data["id"] = cost_item.id() + cost_item_data["name"] = cost_item.Name + cost_item_data["is_nested_by"] = [] + cost_item_data["is_sum"] = cls.is_cost_item_sum(cost_item) + data.append(cost_item_data) + else: + return None + for rel in cost_item.IsNestedBy or []: + for sub_cost_item in rel.RelatedObjects or []: + cls.create_cost_item_json(sub_cost_item, cost_items, cost_item_data["is_nested_by"]) + + @classmethod + def is_cost_item_sum(cls, cost_item: ifcopenshell.entity_instance) -> bool: + cost_values = [] + if cost_item.is_a("IfcCostItem"): + cost_values = cost_item.CostValues + elif cost_item.is_a("IfcCostValue"): + cost_values = cost_item.Components + for cost_value in cost_values or []: + if cost_value.Category == "*": + return True + return False + + @classmethod + def currency(cls): + unit = tool.Unit.get_project_currency_unit() + if unit: + return {"id": unit.id(), "name": unit.Currency} + + @classmethod + def generate_cost_schedule_browser(cls, cost_chedule) -> None: + if not bpy.context.scene.WebProperties.is_connected: + bpy.ops.bim.connect_websocket_server(page="costing") + tool.Web.load_cost_schedule_web_ui(cost_chedule) + + @classmethod + def get_cost_quantities(cls, cost_item: ifcopenshell.entity_instance) -> dict: + results = { + "quantities": [], + "unit_symbol": None, + } + if not cost_item: + return results + results["quantity_type"] = cost_item.CostQuantities[0].is_a() if cost_item.CostQuantities else None + unit = ( + ifcopenshell.util.unit.get_property_unit(cost_item.CostQuantities[0], tool.Ifc.get()) + if cost_item.CostQuantities + else None + ) + if unit: + results["unit_symbol"] = ifcopenshell.util.unit.get_unit_symbol(unit) + for quantity in cost_item.CostQuantities or []: + assigned_product = cls.get_assigned_product(cost_item, quantity) + info = quantity.get_info() + info["fromProduct"] = assigned_product.get_info(recursive=True) if assigned_product else None + results["quantities"].append(info) + if results["quantity_type"] == "IfcQuantityCount": + results["unit_symbol"] = "U" + return results diff --git a/src/bonsai/bonsai/tool/covering.py b/src/bonsai/bonsai/tool/covering.py index a0461e99f7..d682761ae3 100644 --- a/src/bonsai/bonsai/tool/covering.py +++ b/src/bonsai/bonsai/tool/covering.py @@ -49,6 +49,8 @@ class Covering(bonsai.core.tool.Covering): def covering_poll_wall_selected( cls, operator: type[bpy.types.Operator], context: bpy.types.Context, covering_type: str ) -> bool: + if not tool.Ifc.get(): + return False if not context.selected_objects or not context.active_object: operator.poll_message_set("No objects selected.") return False @@ -62,6 +64,8 @@ class Covering(bonsai.core.tool.Covering): def covering_poll_relating_type_check( cls, operator: type[bpy.types.Operator], context: bpy.types.Context, covering_type: str ) -> bool: + if not tool.Ifc.get(): + return False props = context.scene.BIMModelProperties relating_type_id = tool.Blender.get_enum_safe(props, "relating_type_id") if relating_type_id is not None: diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 17125a2113..972ea37b08 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -19,31 +19,41 @@ import os import bpy import ifcopenshell.express +import ifcopenshell.express.schema +import ifcopenshell.express.schema_class +import ifcopenshell.util.element import bonsai.core.tool import bonsai.tool as tool from bonsai.bim.ifc import IfcStore +from mathutils import Vector +from typing import Iterable class Debug(bonsai.core.tool.Debug): @classmethod - def add_schema_identifier(cls, schema): + def add_schema_identifier(cls, schema: ifcopenshell.express.schema_class.SchemaClass) -> None: IfcStore.schema_identifiers.append(schema.schema_name) @classmethod - def load_express(cls, filename): + def load_express(cls, filename: str) -> ifcopenshell.express.schema_class.SchemaClass: schema = ifcopenshell.express.parse(filename) ifcopenshell.register_schema(schema) return schema @classmethod - def purge_hdf5_cache(cls): + def purge_hdf5_cache(cls) -> None: cache_dir = os.path.join(bpy.context.scene.BIMProperties.data_dir, "cache") filelist = [f for f in os.listdir(cache_dir) if f.endswith(".h5")] for f in filelist: - os.remove(os.path.join(cache_dir, f)) + try: + os.remove(os.path.join(cache_dir, f)) + except PermissionError: + pass @classmethod - def debug_geometry(cls, verts=[], edges=[], name="Debug"): + def debug_geometry( + cls, verts: list[Vector] = [], edges: list[tuple[int, int]] = [], name: str = "Debug" + ) -> bpy.types.Object: mesh = bpy.data.meshes.new("Debug") mesh.from_pydata(verts, edges, []) obj = bpy.data.objects.new(name, mesh) @@ -51,13 +61,13 @@ class Debug(bonsai.core.tool.Debug): return obj @classmethod - def remove_unused_elements(cls, elements): + def remove_unused_elements(cls, elements: list[ifcopenshell.entity_instance]) -> None: ifc_file = tool.Ifc.get() for element in elements: ifcopenshell.util.element.remove_deep2(ifc_file, element) @classmethod - def print_unused_elements_stats(cls, requested_ifc_class="", ignore_classes=tuple()): + def print_unused_elements_stats(cls, requested_ifc_class: str = "", ignore_classes: Iterable[str] = tuple()) -> int: ifc_file = tool.Ifc.get() # get list of ifc classes used in model diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 0b8dc7533e..eda2ad4e6e 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -387,12 +387,8 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def ensure_unique_identification(cls, identification: str) -> str: - if tool.Ifc.get_schema() == "IFC2X3": - ids = [d.DocumentId for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "DOCUMENTATION"] - else: - ids = [ - d.Identification for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "DOCUMENTATION" - ] + attr = "DocumentId" if tool.Ifc.get_schema() == "IFC2X3" else "Identification" + ids = [getattr(d, attr) for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "SHEET"] while identification in ids: identification += "-X" return identification @@ -568,7 +564,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def generate_sheet_identification(cls) -> str: - number = len([d for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "DOCUMENTATION"]) + number = len([d for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "SHEET"]) return "A" + str(number).zfill(2) @classmethod @@ -695,8 +691,8 @@ class Drawing(bonsai.core.tool.Drawing): shape = ifcopenshell.geom.create_shape(settings, drawing) camera = tool.Loader.create_camera(drawing, representation, shape) tool.Loader.link_mesh(shape, camera) - obj = bpy.data.objects.new(tool.Loader.get_name(drawing), camera) + cls.import_camera_props(drawing, camera) tool.Ifc.link(drawing, obj) @@ -711,6 +707,25 @@ class Drawing(bonsai.core.tool.Drawing): return obj + @classmethod + def import_temporary_drawing_camera(cls, drawing: ifcopenshell.entity_instance) -> bpy.types.Object: + settings = ifcopenshell.geom.settings() + + representation = ifcopenshell.util.representation.get_representation(drawing, "Model", "Body", "MODEL_VIEW") + assert representation + + shape = ifcopenshell.geom.create_shape(settings, drawing) + camera = tool.Loader.create_camera(drawing, representation, shape) + if obj := bpy.data.objects.get("TemporaryDrawingCamera"): + obj.data = camera + else: + obj = bpy.data.objects.new(tool.Loader.get_name(drawing), camera) + mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape)) + obj.matrix_world = mat + if cls.get_drawing_target_view(drawing) == "REFLECTED_PLAN_VIEW": + obj.matrix_world[1][1] *= -1 + return obj + @classmethod def import_camera_props(cls, drawing: ifcopenshell.entity_instance, camera: bpy.types.Camera) -> None: from bonsai.bim.module.drawing.prop import get_diagram_scales @@ -851,6 +866,15 @@ class Drawing(bonsai.core.tool.Drawing): props = context.scene.DocProperties return next(s for s in props.sheets[: props.active_sheet_index + 1][::-1] if s.is_sheet) + @classmethod + def get_active_drawing_item(cls) -> Union[bpy.types.PropertyGroup, None]: + props = bpy.context.scene.DocProperties + drawing_index = props.active_drawing_index + if len(props.drawings) > drawing_index >= 0: + item = props.drawings[drawing_index] + if item.is_drawing: + return item + @classmethod def import_text_attributes(cls, obj: bpy.types.Object) -> None: from bonsai.bim.module.drawing.prop import BOX_ALIGNMENT_POSITIONS @@ -1669,20 +1693,17 @@ class Drawing(bonsai.core.tool.Drawing): elements = {e for e in (elements & base_elements) if e.is_a() != "IfcSpace"} updated_set = set() - for i in elements: # exclude annotations to avoid including annotations from other drawings if not i.is_a("IfcAnnotation"): updated_set.add(i) # add aggregate too, if element is host by one - if i.Decomposes: - aggregate = i.Decomposes[0].RelatingObject + if decomposes := i.Decomposes: + aggregate = decomposes[0].RelatingObject # remove IfcProject for class iterator. See https://github.com/IfcOpenShell/IfcOpenShell/issues/4361#issuecomment-2081223615 - if not aggregate.is_a("IfcProject"): + if aggregate.is_a("IfcProduct"): updated_set.add(aggregate) - - # After the iteration is complete, update elements with updated set - elements.update(updated_set) + elements = updated_set # add annotations from the current drawing annotations = tool.Drawing.get_group_elements(tool.Drawing.get_drawing_group(drawing)) @@ -1733,13 +1754,20 @@ class Drawing(bonsai.core.tool.Drawing): element = tool.Ifc.get_entity(obj) product = cls.get_assigned_product(element) if product: - tool.Ifc.get_object(product).select_set(True) + product_obj = tool.Ifc.get_object(product) + product_obj.select_set(True) + context.view_layer.objects.active = product_obj @classmethod def is_drawing_active(cls) -> bool: camera = bpy.context.scene.camera area = tool.Blender.get_view3d_area() - return camera and camera.type == "CAMERA" and camera.BIMObjectProperties.ifc_definition_id and area + return bool( + camera is not None + and camera.type == "CAMERA" + and camera.BIMObjectProperties.ifc_definition_id + and area is not None + ) @classmethod def is_camera_orthographic(cls) -> bool: @@ -1782,6 +1810,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def activate_drawing(cls, camera: bpy.types.Object) -> None: selected_objects_before = bpy.context.selected_objects + non_ifc_objects_hide = {o: o.hide_get() for o in bpy.context.view_layer.objects if not tool.Ifc.get_entity(o)} # Sync viewport objects visibility with selectors from EPset_Drawing/Include and /Exclude drawing = tool.Ifc.get_entity(camera) @@ -1831,6 +1860,8 @@ class Drawing(bonsai.core.tool.Drawing): element_obj_names = set() for element in filtered_elements: obj = tool.Ifc.get_object(element) + if not obj: + continue current_representation = tool.Geometry.get_active_representation(obj) if current_representation: subcontext = current_representation.ContextOfItems @@ -1862,11 +1893,12 @@ class Drawing(bonsai.core.tool.Drawing): element_obj_names.add(obj.name) # Note that render visibility is only set on drawing generation time for speed. - [ - obj.hide_set(False) # Show the object - for obj in bpy.context.view_layer.objects - if obj.name in element_obj_names or not tool.Ifc.get_entity(obj) - ] + for obj in bpy.context.view_layer.objects: + if obj.name in element_obj_names: + obj.hide_set(False) # Show the object + continue + if (hide := non_ifc_objects_hide.get(obj)) is not None: + obj.hide_set(hide) cls.import_camera_props(drawing, camera.data) @@ -1876,7 +1908,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def get_elements_in_camera_view( - cls, camera: bpy.types.Object, objs: list[ifcopenshell.entity_instance] + cls, camera: bpy.types.Object, objs: list[bpy.types.Object] ) -> set[ifcopenshell.entity_instance]: props = camera.data.BIMCameraProperties x = props.width @@ -1887,7 +1919,8 @@ class Drawing(bonsai.core.tool.Drawing): [ tool.Ifc.get_entity(o) for o in objs - if cls.is_in_camera_view(o, camera_inverse_matrix, x, y, camera.data.clip_start, camera.data.clip_end) + if o + and cls.is_in_camera_view(o, camera_inverse_matrix, x, y, camera.data.clip_start, camera.data.clip_end) and tool.Ifc.get_entity(o) ] ) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 2ab8d261e2..d319435335 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -22,13 +22,18 @@ import struct import hashlib import logging import numpy as np +import multiprocessing import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.grid import ifcopenshell.geom import ifcopenshell.guid import ifcopenshell.util.element +import ifcopenshell.util.placement import ifcopenshell.util.representation +import ifcopenshell.util.shape_builder import ifcopenshell.util.system +import ifcopenshell.util.unit import bonsai.core.tool import bonsai.core.drawing import bonsai.core.geometry @@ -91,17 +96,53 @@ class Geometry(bonsai.core.tool.Geometry): def delete_data(cls, data: bpy.types.Mesh) -> None: bpy.data.meshes.remove(data) + @classmethod + 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: + return True + elif ( + element.is_a("IfcPositioningElement") or element.is_a("IfcGrid") or element.is_a("IfcGridAxis") + ) and bpy.context.scene.BIMGridProperties.is_locked: + return True + return False + + @classmethod + def lock_object(cls, obj: bpy.types.Object) -> None: + obj.lock_location = (True, True, True) + obj.lock_rotation = (True, True, True) + + @classmethod + def unlock_object(cls, obj: bpy.types.Object) -> None: + obj.lock_location = (False, False, False) + obj.lock_rotation = (False, False, False) + + @classmethod + def delete_ifc_item(cls, obj: bpy.types.Object) -> None: + props = bpy.context.scene.BIMGeometryProperties + if len(props.item_objs) == 1: + return + for i, item_obj in enumerate(props.item_objs): + if item_obj.obj == obj: + props.item_objs.remove(i) + break + item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + cls.remove_representation_item(item) + cls.reload_representation(props.representation_obj) + bpy.data.objects.remove(obj) + @classmethod def delete_ifc_object(cls, obj: bpy.types.Object) -> None: element = tool.Ifc.get_entity(obj) if not element: return - if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": + elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) - if element.is_a("IfcRelSpaceBoundary"): + elif element.is_a("IfcRelSpaceBoundary"): ifcopenshell.api.run("boundary.remove_boundary", tool.Ifc.get(), boundary=element) return bpy.data.objects.remove(obj) - if element.is_a("IfcGridAxis"): + elif element.is_a("IfcGridAxis"): is_last_axis = False # Deleting the last W axis is OK if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( @@ -112,6 +153,12 @@ class Geometry(bonsai.core.tool.Geometry): return ifcopenshell.api.run("grid.remove_grid_axis", tool.Ifc.get(), axis=element) return bpy.data.objects.remove(obj) + elif element.is_a("IfcGrid"): + axes = list(element.UAxes or []) + list(element.VAxes or []) + list(element.WAxes or []) + for axis in axes: + if axis_obj := tool.Ifc.get_object(axis): + bpy.data.objects.remove(axis_obj) + ifcopenshell.api.grid.remove_grid_axis(tool.Ifc.get(), axis=axis) collection = obj.BIMObjectProperties.collection if collection: @@ -138,7 +185,7 @@ class Geometry(bonsai.core.tool.Geometry): if element.VoidsElements: bpy.ops.bim.remove_opening(opening_id=element.id()) else: - is_spatial = element.is_a("IfcSpatialElement") or element.is_a("IfcSpatialStructureElement") + is_spatial = tool.Root.is_spatial_element(element) if getattr(element, "HasOpenings", None): for rel in element.HasOpenings: bpy.ops.bim.remove_opening(opening_id=rel.RelatedOpeningElement.id()) @@ -155,12 +202,23 @@ class Geometry(bonsai.core.tool.Geometry): 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 bpy.data.objects.remove(obj) except: pass @classmethod 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) + if ( + mesh_element.is_a("IfcShapeRepresentation") + and ifcopenshell.util.representation.resolve_representation(mesh_element).RepresentationType + == "AdvancedBrep" + ) or mesh_element.is_a("IfcAdvancedBrep"): + return if obj.data and "ios_edges" in obj.data: bm = bmesh.new() bm.from_mesh(obj.data) @@ -363,6 +421,11 @@ class Geometry(bonsai.core.tool.Geometry): 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) + @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) + @classmethod def get_active_representation_context(cls, obj: bpy.types.Object) -> ifcopenshell.entity_instance: active_representation = tool.Geometry.get_active_representation(obj) @@ -382,23 +445,16 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_representations_iter(cls, element: ifcopenshell.entity_instance) -> Iterator[ifcopenshell.entity_instance]: - if element.is_a("IfcProduct") and (rep := element.Representation): - for r in rep.Representations: - yield r - elif element.is_a("IfcTypeProduct") and (maps := element.RepresentationMaps): - for r in maps: - yield r.MappedRepresentation + return ifcopenshell.util.representation.get_representations_iter(element) @classmethod def get_representation_by_context( cls, element: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance ) -> Union[ifcopenshell.entity_instance, None]: - for r in cls.get_representations_iter(element): - if r.ContextOfItems == context: - return r + return ifcopenshell.util.representation.get_representation(element, context) @classmethod - def get_cartesian_point_coordinate_offset(cls, obj: bpy.types.Object) -> Union[Vector, None]: + def get_cartesian_point_offset(cls, obj: bpy.types.Object) -> Vector | None: if ( obj.BIMObjectProperties.blender_offset_type == "CARTESIAN_POINT" and obj.BIMObjectProperties.cartesian_point_offset @@ -572,61 +628,121 @@ class Geometry(bonsai.core.tool.Geometry): return False @classmethod - def import_representation( + def reimport_element_representations( cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True ) -> Union[bpy.types.Mesh, bpy.types.Curve]: + element = tool.Ifc.get_entity(obj) + assert element + + elements = set() + element_types = set() + representation = ifcopenshell.util.representation.resolve_representation(representation) + context = representation.ContextOfItems + for mapped_element in ifcopenshell.util.element.get_elements_by_representation(tool.Ifc.get(), representation): + if mapped_element.is_a("IfcTypeProduct"): + element_types.add(mapped_element) + else: + elements.add(mapped_element) + if element_type := ifcopenshell.util.element.get_type(mapped_element): + element_types.add(element_type) + logger = logging.getLogger("ImportIFC") ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) - element = tool.Ifc.get_entity(obj) - assert element # Type checker. settings = ifcopenshell.geom.settings() settings.set("weld-vertices", True) settings.set("apply-default-materials", False) + settings.set("layerset-first", True) settings.set("keep-bounding-boxes", True) - context = representation.ContextOfItems + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) ifc_importer.file = tool.Ifc.get() - # create_shape doesn't support point cloud representations. - if representation.RepresentationType in ("PointCloud", "Point"): - mesh = tool.Loader.create_point_cloud_mesh(representation) - if mesh is None: - raise Exception(f"Failed to process point cloud representation: {representation}.") - return mesh + # TODO support fallbacks like for point clouds - if element.is_a("IfcTypeProduct"): - # You may only specify a single representation when creating shapes for types - try: - shape = ifcopenshell.geom.create_shape(settings, representation) - except: - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, representation) + settings.set("context-ids", [context.id()]) + if not apply_openings: + settings.set("disable-opening-subtractions", True) + + shape = None + if elements: + iterator = ifcopenshell.geom.iterator( + settings, tool.Ifc.get(), multiprocessing.cpu_count(), include=elements + ) else: - if not apply_openings: - settings.set("disable-opening-subtractions", True) + iterator = None # For example, when switching representation of a type with no occurrences + meshes = {} + if iterator and iterator.initialize(): + while True: + shape = iterator.get() + element = tool.Ifc.get().by_id(shape.id) + if obj := tool.Ifc.get_object(element): + mesh_name = tool.Loader.get_mesh_name_from_shape(shape.geometry) + mesh = meshes.get(mesh_name) + if mesh is None: + # Duplicate code + representation = tool.Ifc.get().by_id(int(shape.geometry.id.split("-")[0])) + if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": + mesh = tool.Loader.create_camera(element, representation, shape) + if element.is_a("IfcAnnotation") and ifc_importer.is_curve_annotation(element): + mesh = ifc_importer.create_curve(element, shape) + elif shape: + mesh = ifc_importer.create_mesh( + element, shape, cartesian_point_offset=cls.get_cartesian_point_offset(obj) or False + ) + 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 + if not shape_has_openings: + tool.Loader.load_indexed_colour_map(representation, mesh) + tool.Loader.link_mesh(shape, mesh) + meshes[mesh_name] = mesh - if context.ContextIdentifier == "Body" and context.TargetView == "MODEL_VIEW": - try: - shape = ifcopenshell.geom.create_shape(settings, element, representation) - except: - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, element, representation) - else: - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, element, representation) + old_mesh = obj.data + cls.change_object_data(obj, mesh, is_global=False) + cls.record_object_materials(obj) + if not cls.has_data_users(old_mesh): + cls.delete_data(old_mesh) + cls.clear_modifiers(obj) + cls.clear_cache(element) - if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": - mesh = tool.Loader.create_camera(element, representation, shape) - if element.is_a("IfcAnnotation") and ifc_importer.is_curve_annotation(element): - mesh = ifc_importer.create_curve(element, shape) - elif shape: - mesh = ifc_importer.create_mesh(element, shape) - ifc_importer.material_creator.load_existing_materials() - ifc_importer.material_creator.create(element, obj, mesh) - mesh.BIMMeshProperties.has_openings_applied = apply_openings + if not iterator.next(): + break - return mesh + for element in element_types: + if obj := tool.Ifc.get_object(element): + if representation := ifcopenshell.util.representation.get_representation(element, context): + geometry = ifcopenshell.geom.create_shape(settings, representation) + mesh_name = tool.Loader.get_mesh_name_from_shape(geometry) + mesh = meshes.get(mesh_name) + if mesh is None: + # Duplicate code + representation = tool.Ifc.get().by_id(int(geometry.id.split("-")[0])) + if geometry: + mesh = ifc_importer.create_mesh(element, 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 + if not shape_has_openings: + tool.Loader.load_indexed_colour_map(representation, mesh) + tool.Loader.link_mesh(geometry, mesh) + meshes[mesh_name] = mesh + + old_mesh = obj.data + cls.change_object_data(obj, mesh, is_global=False) + cls.record_object_materials(obj) + if not cls.has_data_users(old_mesh): + cls.delete_data(old_mesh) + cls.clear_modifiers(obj) + cls.clear_cache(element) + + @classmethod + def does_shape_has_openings( + cls, shape: Union[ifcopenshell.geom.ShapeElementType, ifcopenshell.geom.ShapeType] + ) -> bool: + return "openings" in getattr(shape, "geometry", shape).id @classmethod def import_representation_parameters(cls, data: bpy.types.Mesh) -> None: @@ -722,6 +838,10 @@ class Geometry(bonsai.core.tool.Geometry): return True return False + @classmethod + def is_meshlike_item(cls, item: ifcopenshell.entity_instance) -> bool: + return item.is_a("IfcTessellatedItem") or item.is_a("IfcManifoldSolidBrep") + @classmethod def is_profile_based(cls, data: bpy.types.Mesh) -> bool: return data.BIMMeshProperties.subshape_type == "PROFILE" @@ -732,6 +852,14 @@ class Geometry(bonsai.core.tool.Geometry): "SweptSolid", ) + @classmethod + def is_representation_item(cls, obj: bpy.types.Object) -> bool: + return ( + obj.data + and (ifc_id := obj.data.BIMMeshProperties.ifc_definition_id) + and tool.Ifc.get().by_id(ifc_id).is_a("IfcRepresentationItem") + ) + @classmethod def is_text_literal(cls, representation: ifcopenshell.entity_instance) -> bool: items = ifcopenshell.util.representation.resolve_items(representation) @@ -1020,12 +1148,16 @@ class Geometry(bonsai.core.tool.Geometry): representation = inverse if styled_item: + consider_inverses.remove(styled_item) ifc_file.remove(styled_item) if layer and len(layer.Items) == 1: - ifc_file.remove(styled_item) + consider_inverses.remove(layer) + ifc_file.remove(layer) if colour: + consider_inverses.remove(colour) ifcopenshell.util.element.remove_deep2(ifc_file, colour) if texture: + consider_inverses.remove(texture) ifcopenshell.util.element.remove_deep2(ifc_file, texture) for shape_aspect in shape_aspects: @@ -1277,3 +1409,109 @@ class Geometry(bonsai.core.tool.Geometry): use_immediate_repr = apply_openings and bool(getattr(element, "HasOpenings", None)) use_immediate_repr = use_immediate_repr or cls.has_material_style_override(element) return use_immediate_repr + + @classmethod + def get_elements_by_representation( + cls, representation: ifcopenshell.entity_instance + ) -> set[ifcopenshell.entity_instance]: + return ifcopenshell.util.element.get_elements_by_representation(tool.Ifc.get(), representation) + + @classmethod + def sync_item_positions(cls) -> None: + props = bpy.context.scene.BIMGeometryProperties + if not props.representation_obj: + return + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + rep_obj = props.representation_obj + + coordinate_offset = cls.get_cartesian_point_offset(rep_obj) + rep_matrix = rep_obj.matrix_world.copy() + if coordinate_offset: + rep_matrix.translation -= coordinate_offset + rep_matrix_i = rep_matrix.inverted() + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + has_changed = False + + for item_obj in props.item_objs: + obj = item_obj.obj + item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + if item.is_a("IfcSweptAreaSolid"): + if not tool.Ifc.is_moved(obj): + continue + has_changed = True + old_position = item.Position + + if np.allclose(np.array(rep_matrix), np.array(obj.matrix_world), atol=1e-4): + if old_position: + item.Position = None + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_position) + continue + + position = rep_matrix_i @ obj.matrix_world + position.translation /= unit_scale + item.Position = builder.create_axis2_placement_3d_from_matrix(np.array(position)) + if old_position: + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_position) + if has_changed: + cls.reload_representation(rep_obj) + + @classmethod + def import_item_attributes(cls, obj: bpy.types.Object) -> None: + props = obj.data.BIMMeshProperties + props.item_attributes.clear() + item = tool.Ifc.get().by_id(props.ifc_definition_id) + if item.is_a("IfcExtrudedAreaSolid"): + new = props.item_attributes.add() + new.name = "Depth" + new.float_value = item.Depth + + @classmethod + def import_item(cls, obj: bpy.types.Object) -> None: + props = bpy.context.scene.BIMGeometryProperties + 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) + geometry = tool.Loader.create_generic_shape(item) + obj.data.clear_geometry() + tool.Loader.convert_geometry_to_mesh(geometry, obj.data) + + rep_obj = props.representation_obj + obj.matrix_world = rep_obj.matrix_world.copy() + if coordinate_offset := cls.get_cartesian_point_offset(rep_obj): + for vert in obj.data.vertices: + vert.co -= coordinate_offset + + if item.is_a("IfcSweptAreaSolid"): + if item.Position: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + position = Matrix(ifcopenshell.util.placement.get_axis2placement(item.Position).tolist()) + position.translation *= unit_scale + item_matrix = rep_obj.matrix_world.copy() + if coordinate_offset: + item_matrix.translation -= coordinate_offset + item_matrix = item_matrix @ position + + transformation = obj.matrix_world.inverted() @ item_matrix + transformation_i = transformation.inverted() + + obj.matrix_world = item_matrix + + for vert in obj.data.vertices: + vert.co = transformation_i @ vert.co + cls.record_object_position(obj) + + @classmethod + def disable_item_mode(cls) -> None: + props = bpy.context.scene.BIMGeometryProperties + if props.representation_obj: + props.representation_obj.hide_set(False) + cls.unlock_object(props.representation_obj) + tool.Blender.set_active_object(props.representation_obj) + cls.sync_item_positions() + props.is_changing_mode = True + if props.mode != "OBJECT": + props.mode = "OBJECT" + props.is_changing_mode = False + props.representation_obj = None diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py index af7f3a3bf9..10fb71cf3b 100644 --- a/src/bonsai/bonsai/tool/ifc.py +++ b/src/bonsai/bonsai/tool/ifc.py @@ -22,6 +22,7 @@ import numpy as np import ifcopenshell.api import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper import ifcopenshell.util.element +import ifcopenshell.util.schema import bonsai.core.tool import bonsai.bim.handler import bonsai.tool as tool @@ -60,7 +61,7 @@ class Ifc(bonsai.core.tool.Ifc): return IfcStore.path @classmethod - def get_schema(cls) -> str: + def get_schema(cls) -> ifcopenshell.util.schema.IFC_SCHEMA: if IfcStore.get_file(): return IfcStore.get_file().schema @@ -71,17 +72,23 @@ class Ifc(bonsai.core.tool.Ifc): @classmethod def is_moved(cls, obj: bpy.types.Object) -> bool: element = cls.get_entity(obj) - if not element or element.is_a("IfcTypeProduct") or element.is_a("IfcProject"): + 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: 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 + 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_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)) + if angle_rad > 0.0017453292519943296: # 0.1 degrees + return True + return False @classmethod def schema(cls) -> ifcopenshell_wrapper.schema_definition: @@ -196,13 +203,13 @@ class Ifc(bonsai.core.tool.Ifc): IfcStore.edited_objs.discard(obj) @classmethod - def resolve_uri(cls, uri): + def resolve_uri(cls, uri: str) -> str: if os.path.isabs(uri): return uri ifc_path = cls.get_path() if os.path.isfile(ifc_path): ifc_path = os.path.dirname(ifc_path) - return (uri if not uri or os.path.isabs(uri) else os.path.join(ifc_path, uri)).replace("\\", "/") + return (uri if not uri else os.path.join(ifc_path, uri)).replace("\\", "/") @classmethod def get_relative_uri(cls, uri): diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 222239598d..0fcc9abc3b 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -79,7 +79,11 @@ class IfcGit: else: return None - if IfcGitRepo.repo is not None and IfcGitRepo.repo.working_dir == path_dir: + if ( + IfcGitRepo.repo is not None + and os.path.exists(IfcGitRepo.repo.git_dir) + and IfcGitRepo.repo.working_dir == path_dir + ): return IfcGitRepo.repo try: @@ -88,9 +92,11 @@ class IfcGit: parentdir_path = os.path.abspath(os.path.join(path_dir, os.pardir)) if parentdir_path == path_dir: # root folder + IfcGitRepo.repo = None return None return cls.repo_from_path(parentdir_path) except git.exc.NoSuchPathError: + IfcGitRepo.repo = None return None if repo: IfcGitRepo.repo = repo @@ -100,7 +106,7 @@ class IfcGit: def add_file_to_repo(cls, repo: git.Repo, path_file: str) -> None: if os.name == "nt": cls.dos2unix(path_file) - repo.index.add(path_file) + repo.index.add(os.path.normpath(path_file)) repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir)) bpy.ops.ifcgit.refresh() @@ -124,7 +130,7 @@ class IfcGit: repo = IfcGitRepo.repo if os.name == "nt": cls.dos2unix(path_file) - repo.index.add(path_file) + repo.index.add(os.path.normpath(path_file)) repo.index.commit(message=props.commit_message) props.commit_message = "" @@ -180,8 +186,6 @@ class IfcGit: @classmethod def clear_commits_list(cls) -> None: - area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") - area.spaces[0].shading.color_type = "MATERIAL" props = bpy.context.scene.IfcGitProperties # ifcgit_commits is registered list widget @@ -345,8 +349,7 @@ class IfcGit: current_revision = repo.commit() if selected_revision == current_revision: - area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") - area.spaces[0].shading.color_type = "MATERIAL" + cls.decolourise() return if current_revision.committed_date > selected_revision.committed_date: @@ -408,6 +411,11 @@ class IfcGit: else: obj.color = (1.0, 1.0, 1.0, 0.5) + @classmethod + def decolourise(cls) -> None: + area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") + area.spaces[0].shading.color_type = "MATERIAL" + @classmethod def switch_to_revision_item(cls) -> None: props = bpy.context.scene.IfcGitProperties @@ -511,7 +519,7 @@ class IfcGit: else: if os.name == "nt": cls.dos2unix(path_ifc) - repo.index.add(path_ifc) + repo.index.add(os.path.normpath(path_ifc)) repo.git.commit("--no-edit") except git.exc.GitError: operator.report({"ERROR"}, "Unknown IFC Merge failure") @@ -521,6 +529,7 @@ class IfcGit: cls.load_project(path_ifc) cls.refresh_revision_list(path_ifc) + cls.decolourise() @classmethod def entity_log(cls, path_ifc: str, step_id: int) -> str: diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 701ea6a83e..7cbcb7d13d 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -19,9 +19,10 @@ from __future__ import annotations import os import re -import math import bpy +import math import bmesh +import logging import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.geolocation @@ -57,23 +58,16 @@ class Loader(bonsai.core.tool.Loader): cls.unit_scale = unit_scale @classmethod - def set_settings(cls, settings: bonsai.bim.import_ifc.IfcImportSettings) -> None: - cls.settings = settings + def load_settings(cls) -> None: + logger = logging.getLogger("ImportIFC") + cls.settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) + cls.settings.contexts = ifcopenshell.util.representation.get_prioritised_contexts(tool.Ifc.get()) + cls.settings.context_settings = cls.create_settings() + cls.settings.gross_context_settings = cls.create_settings(is_gross=True) @classmethod - def create_project_collection(cls, name: str) -> bpy.types.Collection: - project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - project_collection = project_obj.BIMObjectProperties.collection - for collection in project_collection.children: - if collection.name == name: - return collection - collection = bpy.data.collections.new(name) - project_collection.children.link(collection) - if name == "Types": - project_layer = bpy.context.view_layer.layer_collection.children.get(project_collection.name) - if project_layer: - project_layer.children[collection.name].hide_viewport = True - return collection + def set_settings(cls, settings: bonsai.bim.import_ifc.IfcImportSettings) -> None: + cls.settings = settings @classmethod def get_mesh_name_from_shape(cls, geometry: ifcopenshell.geom.ShapeType) -> str: @@ -106,11 +100,7 @@ class Loader(bonsai.core.tool.Loader): mesh: tool.Geometry.TYPES_WITH_MESH_PROPERTIES, ) -> None: geometry = shape.geometry if hasattr(shape, "geometry") else shape - if "-" in geometry.id: - mesh.BIMMeshProperties.ifc_definition_id = int(geometry.id.split("-")[0]) - else: - # TODO: See #2002 - mesh.BIMMeshProperties.ifc_definition_id = int(geometry.id.replace(",", "")) + mesh.BIMMeshProperties.ifc_definition_id = int(geometry.id.split("-")[0]) @classmethod def create_surface_style_shading(cls, blender_material, surface_style): @@ -311,7 +301,6 @@ class Loader(bonsai.core.tool.Loader): value = texture["RasterCode"] image_bytes = int(value, 2).to_bytes(len(value) // 8, "big") pil_image = Image.open(io.BytesIO(image_bytes)) - pil_image.save("test_image.png") byte_to_normalized = 1.0 / 255.0 bpy_image = bpy.data.images.new("blob_texture", width=pil_image.width, height=pil_image.height) # PIL returns rows ordered from top to bottom, blender from bottom to top @@ -487,20 +476,71 @@ class Loader(bonsai.core.tool.Loader): blender_material.node_tree.links.new(coord.outputs["UV"], node.inputs["Vector"]) @classmethod - def load_indexed_texture_map(cls, coordinates: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None: + def load_indexed_colour_map( + cls, representation_or_item: ifcopenshell.entity_instance, mesh: bpy.types.Mesh + ) -> None: + """Ensure indexed colour map is loaded for representation if it's available. + + Method doesn't support elements with openings, see #5405. + + :param representation: IfcShapeRepresentation or IfcRepresentationItem of any type. + Representation may not have an indexed colour map, + method will automatically check if it does and will skip it otherwise. + + :raises AssertionError: If mesh doesn't match the representation exactly, which usually occurs + if element geometry is altered by openings. + """ + + is_representation = representation_or_item.is_a("IfcShapeRepresentation") + + if is_representation: + if representation_or_item.RepresentationType != "Tessellation": + return + items = representation_or_item.Items + else: + items = [representation_or_item] + + colours = [] + for item in items: + if not item.is_a("IfcTessellatedFaceSet"): + continue + # It's unclear what has priority, styled by item or indexed maps + # Given that indexed maps currently are super expensive, I'll prioritise styled by item + if item.StyledByItem: + continue + colours.extend(item.HasColours) + + if not colours: + return + + for colour in colours: + cls.load_indexed_map(colour, mesh) + + @classmethod + def load_indexed_map(cls, index_map: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None: + """Add data from index map as blender mesh attribute. + + :param index_map: IfcIndexedTextureMap or IfcIndexedColourMap + """ + + map_type = "UV" if index_map.is_a("IfcIndexedTextureMap") else "Color" + # Get a BMesh representation bm = bmesh.new() bm.from_mesh(mesh) - # constistent naming with how Blender does it - uv_layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap") + if map_type == "UV": + # constistent naming with how Blender does it + layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap") + else: + layer = bm.loops.layers.float_color.new("Color") # remap the faceset CoordList index to the vertices in blender mesh coordinates_remap = [] si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - faceset = coordinates.MappedTo + faceset = index_map.MappedTo for co in faceset.Coordinates.CoordList: co = Vector(co) * si_conversion - index = next(v.index for v in bm.verts if (v.co - co).length_squared < 1e-5) + index = min(bm.verts, key=lambda v: (v.co - co).length_squared).index coordinates_remap.append(index) # ifc indices start with 1 @@ -508,37 +548,64 @@ class Loader(bonsai.core.tool.Loader): # faces_remap - ifc faces described using blender verts indices # IFC4.3+ - if coordinates.is_a("IfcIndexedPolygonalTextureMap"): + if index_map.is_a("IfcIndexedPolygonalTextureMap"): faces_remap = [ remap_verts_to_blender(tex_coord_index.TexCoordsOf.CoordIndex) - for tex_coord_index in coordinates.TexCoordIndices + for tex_coord_index in index_map.TexCoordIndices ] - texture_map = [tex_coord_index.TexCoordIndex for tex_coord_index in coordinates.TexCoordIndices] - else: # IfcIndexedTriangleTextureMap + texture_map = [tex_coord_index.TexCoordIndex for tex_coord_index in index_map.TexCoordIndices] + else: # IfcIndexedTriangleTextureMap or IfcIndexedColourMap if faceset.is_a("IfcTriangulatedFaceSet"): faces_remap = [remap_verts_to_blender(triangle_face) for triangle_face in faceset.CoordIndex] else: # IfcPolygonalFaceSet - faces_remap = [remap_verts_to_blender(triangle_face.CoordIndex) for triangle_face in faceset.Faces] - texture_map = coordinates.TexCoordIndex + faces_remap = [remap_verts_to_blender(face.CoordIndex) for face in faceset.Faces] + if index_map.is_a("IfcIndexedTriangleTextureMap"): + texture_map = index_map.TexCoordIndex + else: + texture_map = index_map.ColourIndex - # apply uv to each face + if map_type == "UV": + data_list = index_map.TexCoords.TexCoordsList + else: + data_list = index_map.Colours.ColourList + opacity = index_map.Opacity + opacity = opacity if opacity is not None else 1.0 + data_list = [d + (opacity,) for d in data_list] + + # Apply attribute to each face for bface in bm.faces: face = [loop.vert.index for loop in bface.loops] - # find the corresponding TexCoordIndex by matching ifc faceset with blender face - # remap TexCoordIndex as the loop start may different from blender face - texCoordIndex = next( - [tex_coord_index[face_remap.index(i)] for i in face] - for tex_coord_index, face_remap in zip(texture_map, faces_remap, strict=True) - if all(i in face_remap for i in face) - ) + # Find the corresponding index in data list by matching ifc faceset with blender face. + data_index = None + for tex_coord_index, face_remap in zip(texture_map, faces_remap, strict=True): + if not all(i in face_remap for i in face): + continue + # 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] + break + + if data_index is None: + # This face may be part of another representation item + continue + # apply uv to each loop - for loop, i in zip(bface.loops, texCoordIndex): - loop[uv_layer].uv = coordinates.TexCoords.TexCoordsList[i - 1] + 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) bm.free() + if map_type == "Color": + # Couldn't find a way to do it from bmesh. + mesh.color_attributes.active_color_index = 0 + @classmethod def is_point_far_away( cls, point: Union[ifcopenshell.entity_instance, npt.NDArray[np.float64]], is_meters: bool = True @@ -568,6 +635,7 @@ class Loader(bonsai.core.tool.Loader): settings.set("context-ids", [context.id()]) settings.set("apply-default-materials", False) settings.set("keep-bounding-boxes", True) + settings.set("layerset-first", True) if is_gross: settings.set("disable-opening-subtractions", True) results.append(settings) @@ -731,43 +799,33 @@ class Loader(bonsai.core.tool.Loader): @classmethod def get_offset_point(cls, ifc_file: ifcopenshell.file) -> Union[npt.NDArray[np.float64], None]: - elements_checked = 0 - # If more than these elements aren't far away, the file probably isn't - # absolutely positioned. We check more than 1 because sometimes users - # try to be clever and put "origin marker" objects. - element_checking_threshold = 3 - elements = ifc_file.by_type("IfcElement") + # Check walls first, as they're usually cheap + elements = ifc_file.by_type("IfcWall") + elements += ifc_file.by_type("IfcElement") if ifc_file.schema not in ("IFC2X3", "IFC4"): - if not elements: - elements = ifc_file.by_type("IfcLinearPositioningElement") - if not elements: - elements = ifc_file.by_type("IfcReferent") - if not elements: - elements = ifc_file.by_type("IfcGrid") + elements += ifc_file.by_type("IfcLinearPositioningElement") + elements += ifc_file.by_type("IfcReferent") + elements += ifc_file.by_type("IfcGrid") if ifc_file.schema == "IFC2X3": - if not elements: - elements = ifc_file.by_type("IfcSpatialStructureElement") + elements += ifc_file.by_type("IfcSpatialStructureElement") else: - if not elements: - elements = ifc_file.by_type("IfcSpatialElement") + elements += ifc_file.by_type("IfcSpatialElement") for element in elements: - if elements_checked > element_checking_threshold: - return if not element.Representation: continue shape = cls.create_generic_shape(element, is_gross=True) if not shape: continue - elements_checked += 1 mat = ifcopenshell.util.shape.get_shape_matrix(shape) point = mat @ np.array((shape.geometry.verts[0], shape.geometry.verts[1], shape.geometry.verts[2], 1.0)) if cls.is_point_far_away(point, is_meters=True): # Arbitrary origins should be to the nearest millimeter. # Anything more precise is just ridiculous from a practical surveying perspective. return [round(float(p), 3) / cls.unit_scale for p in point[:3]] + break @classmethod def guess_false_origin_from_elements(cls, ifc_file: ifcopenshell.file) -> None: @@ -837,3 +895,99 @@ class Loader(bonsai.core.tool.Loader): float(props.blender_x_axis_ordinate), ) return Matrix(matrix.tolist()) + + @classmethod + def convert_geometry_to_mesh( + cls, geometry: ifcopenshell.geom.ShapeType, mesh: bpy.types.Mesh, verts=None + ) -> bpy.types.Mesh: + if verts is None: + verts = geometry.verts + if geometry.faces: + num_vertices = len(verts) // 3 + total_faces = len(geometry.faces) + loop_start = range(0, total_faces, 3) + num_loops = total_faces // 3 + loop_total = [3] * num_loops + num_vertex_indices = len(geometry.faces) + + # See bug 3546 + # ios_edges holds true edges that aren't triangulated. + # + # we do `.tolist()` because Blender can't assign `np.int32` to it's custom attributes + mesh["ios_edges"] = list(set(tuple(e) for e in ifcopenshell.util.shape.get_edges(geometry).tolist())) + mesh["ios_item_ids"] = ifcopenshell.util.shape.get_faces_representation_item_ids(geometry).tolist() + + mesh.vertices.add(num_vertices) + mesh.vertices.foreach_set("co", verts) + mesh.loops.add(num_vertex_indices) + mesh.loops.foreach_set("vertex_index", geometry.faces) + mesh.polygons.add(num_loops) + mesh.polygons.foreach_set("loop_start", loop_start) + mesh.polygons.foreach_set("loop_total", loop_total) + mesh.polygons.foreach_set("use_smooth", [0] * total_faces) + mesh.update() + + rep_str: str = geometry.id + if "openings" not in rep_str: + rep_id = rep_str.split("-", 1)[0] + rep = tool.Ifc.get().by_id(int(rep_id)) + # For now, not necessary to load maps in Item mode + if rep.is_a("IfcShapeRepresentation"): + tool.Loader.load_indexed_colour_map(rep, mesh) + else: + e = geometry.edges + v = verts + vertices = [[v[i], v[i + 1], v[i + 2]] for i in range(0, len(v), 3)] + edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)] + mesh.from_pydata(vertices, edges, []) + # 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 = [] + mesh["ios_edges_item_ids"] = edges_item_ids + + mesh["ios_materials"] = [m.instance_id() for m in geometry.materials] + mesh["ios_material_ids"] = geometry.material_ids + return mesh + + @classmethod + def setup_active_bsdd_classification(cls) -> None: + ifc_file = tool.Ifc.get() + schema = ifc_file.schema + + # In IFC2X3 IfcClassification doesn't have an attribute for uri. + if schema == "IFC2X3": + classifications = [c for c in ifc_file.by_type("IfcClassification") if c.Name] + if not classifications: + return + pattern = r"^https://identifier\.buildingsmart\.org/uri/([a-zA-Z0-9]+)/([a-zA-Z0-9]+)/([0-9]+\.[0-9]+)" + + # No inverse attribute in IFC2X3... + for ref in ifc_file.by_type("IfcClassificationReference"): + if ( + not (uri := ref.Location) + or not uri.startswith("https://identifier.buildingsmart.org/uri/") + or not (pattern_match := re.match(pattern, uri)) + or not (classification := ref.ReferencedSource) + or classification not in classifications + or not classification.is_a("IfcClassification") + ): + continue + tool.Bsdd.set_active_bsdd(classification.Name, pattern_match.group(0)) + return + + attr_name = "Specification" if schema == "IFC4X3" else "Location" + bsdd_classification, uri, name = None, None, None + for c in ifc_file.by_type("IfcClassification"): + if ( + (uri := getattr(c, attr_name)) + and uri.startswith("https://identifier.buildingsmart.org/uri/") + and (name := c.Name) + ): + bsdd_classification = c + break + if not bsdd_classification: + return + assert name and uri + tool.Bsdd.set_active_bsdd(name, uri) diff --git a/src/bonsai/bonsai/tool/material.py b/src/bonsai/bonsai/tool/material.py index 48359829c8..2a12af77e5 100644 --- a/src/bonsai/bonsai/tool/material.py +++ b/src/bonsai/bonsai/tool/material.py @@ -27,7 +27,8 @@ import bonsai.bim.helper import ifcopenshell.util.unit import ifcopenshell.util.element from collections import defaultdict -from typing import Union, Any, TYPE_CHECKING, Optional +from typing import Union, Any, TYPE_CHECKING, Optional, Literal +from typing_extensions import assert_never if TYPE_CHECKING: # Avoid circular imports. @@ -352,3 +353,77 @@ class Material(bonsai.core.tool.Material): elements.extend(tool.Model.get_occurrences_without_material_override(element)) tool.Model.apply_ifc_material_changes(elements, assigned_material=assigned_material) + + @classmethod + def ensure_new_material_set_is_valid(cls, material: ifcopenshell.entity_instance) -> None: + material_type = material.is_a() + + ifc_file = tool.Ifc.get() + default_material = cls.get_default_material() + + if material_type == "IfcMaterialConstituentSet": + material_constituent = ifcopenshell.api.material.add_constituent( + ifc_file, + constituent_set=material, + material=default_material, + ) + material.MaterialConstituents = [material_constituent] + elif material_type == "IfcMaterialLayerSet": + material_layer = ifcopenshell.api.material.add_layer( + ifc_file, + layer_set=material, + material=default_material, + ) + material.MaterialLayers = [material_layer] + elif material_type == "IfcMaterialProfileSet": + profile = tool.Profile.get_default_profile() + material_profile = ifcopenshell.api.material.add_profile( + ifc_file, + profile_set=material, + profile=profile, + material=None, + ) + material.MaterialProfiles = [material_profile] + elif material_type == "IfcMaterialList": + material.Materials = [default_material] + else: + assert False, f"Invalid material type found: {material_type}." + + @classmethod + def purge_unused_materials(cls) -> int: + ifc_file = tool.Ifc.get() + is_ifc2x3 = ifc_file.schema == "IFC2X3" + ifc_class = "IfcMaterial" if is_ifc2x3 else "IfcMaterialDefinition" + + skip_inverses = { + "IfcMaterialDefinitionRepresentation", + "IfcMaterialProperties", + } + + def is_safe_to_purge(material: ifcopenshell.entity_instance) -> bool: + for i in ifc_file.get_inverse(material): + if i.is_a() not in skip_inverses: + return False + return True + + materials = ifc_file.by_type(ifc_class) + # Not IfcMaterialDefinition... + materials += ifc_file.by_type("IfcMaterialList") + # In IFC2X3 materials don't have a common class. + if is_ifc2x3: + materials += ifc_file.by_type("IfcMaterialLayer") + materials += ifc_file.by_type("IfcMaterialLayerSet") + materials += ifc_file.by_type("IfcMaterialLayerSetUsage") + + i = 0 + for material in materials: + if ifc_file.get_total_inverses(material) != 0 and not is_safe_to_purge(material): + continue + ifcopenshell.api.material.remove_material(ifc_file, material) + i += 1 + + if i == 0: + return 0 + + i += cls.purge_unused_materials() + return i diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 4b549364b6..85cf811afb 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -130,36 +130,10 @@ class Model(bonsai.core.tool.Model): if position is None: position = Matrix() - cls.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - helper = Helper(tool.Ifc.get()) - indices = helper.auto_detect_arbitrary_profile_with_voids(obj, obj.data) - - if isinstance(indices, tuple) and indices[0] is False: # Ugly - return - - cls.bm = bmesh.new() - cls.bm.from_mesh(obj.data) - cls.bm.verts.ensure_lookup_table() - cls.bm.edges.ensure_lookup_table() - - if indices["inner_curves"]: - profile = tool.Ifc.get().createIfcArbitraryProfileDefWithVoids("AREA") - else: - profile = tool.Ifc.get().createIfcArbitraryClosedProfileDef("AREA") - - if tool.Ifc.get().schema != "IFC2X3": - cls.points = cls.export_points(position, indices["points"]) - - profile.OuterCurve = cls.export_curve(position, indices["profile"]) - if indices["inner_curves"]: - results = [] - for inner_curve in indices["inner_curves"]: - results.append(cls.export_curve(position, inner_curve)) - profile.InnerCurves = results - - cls.bm.free() - return profile + result = helper.auto_detect_profiles(obj, obj.data, position) + if isinstance(result, dict) and result["profile_def"]: + return tool.Ifc.get().add(result["profile_def"]) @classmethod def export_surface(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: @@ -290,13 +264,15 @@ class Model(bonsai.core.tool.Model): cls.arcs = [] cls.circles = [] - if profile.is_a("IfcArbitraryClosedProfileDef"): - cls.import_curve(obj, position, profile.OuterCurve) - if profile.is_a("IfcArbitraryProfileDefWithVoids"): - for inner_curve in profile.InnerCurves: - cls.import_curve(obj, position, inner_curve) - elif profile.is_a() == "IfcRectangleProfileDef": - cls.import_rectangle(obj, position, profile) + profiles = profile.Profiles if profile.is_a("IfcCompositeProfileDef") else [profile] + for profile in profiles: + if profile.is_a("IfcArbitraryClosedProfileDef"): + cls.import_curve(obj, position, profile.OuterCurve) + if profile.is_a("IfcArbitraryProfileDefWithVoids"): + for inner_curve in profile.InnerCurves: + cls.import_curve(obj, position, inner_curve) + elif profile.is_a() == "IfcRectangleProfileDef": + cls.import_rectangle(obj, position, profile) mesh = bpy.data.meshes.new("Profile") mesh.from_pydata(cls.vertices, cls.edges, []) @@ -456,7 +432,7 @@ class Model(bonsai.core.tool.Model): ifc_importer.material_creator.load_existing_materials() ifc_importer.create_generic_elements(set(openings)) for opening_obj in ifc_importer.added_data.values(): - bpy.context.scene.collection.objects.link(opening_obj) + tool.Collector.assign(opening_obj, should_clean_users_collection=False) return ifc_importer.added_data.values() @classmethod @@ -870,6 +846,14 @@ class Model(bonsai.core.tool.Model): AuthoringData.type_thumbnails[element.id()] = obj.preview.icon_id + BBIM_PARAMETRIC_PSETS = ( + "BBIM_Window", + "BBIM_Door", + "BBIM_Roof", + "BBIM_Railing", + "BBIM_Stair", + ) + @classmethod def get_modeling_bbim_pset_data(cls, object, pset_name): """get modelling BBIM pset data (eg, BBIM_Roof) and loads it's `Data` as json to `data_dict`""" diff --git a/src/bonsai/bonsai/tool/polyline.py b/src/bonsai/bonsai/tool/polyline.py new file mode 100644 index 0000000000..660b41fc1b --- /dev/null +++ b/src/bonsai/bonsai/tool/polyline.py @@ -0,0 +1,504 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 Bruno Perdigão +# +# 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 +import bmesh +import bonsai.core.tool +import bonsai.tool as tool +from bonsai.bim.module.drawing.helper import format_distance +from dataclasses import dataclass +from lark import Lark, Transformer +from math import degrees, radians, sin, cos, tan +from mathutils import Vector, Matrix +from typing import Optional + + +class Polyline(bonsai.core.tool.Polyline): + + @dataclass + class PolylineUI: + _D: str = "" + _A: str = "" + _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 = "" + + def set_value(self, attribute_name, value): + value = str(value) + setattr(self, f"_{attribute_name}", value) + + def get_text_value(self, attribute_name): + value = getattr(self, f"_{attribute_name}") + return value + + def get_number_value(self, attribute_name): + value = getattr(self, f"_{attribute_name}") + if value: + return float(value) + else: + return value + + def get_formatted_value(self, attribute_name): + value = self.get_number_value(attribute_name) + context = bpy.context + if value is None: + return None + if attribute_name == "A": + value = float(self.get_text_value(attribute_name)) + return f"{value:.2f}" + else: + return self.format_input_ui_units(context, value) + + def format_input_ui_units(cls, context, value): + unit_system = tool.Drawing.get_unit_system() + if unit_system == "IMPERIAL": + precision = context.scene.DocProperties.imperial_precision + factor = 3.28084 + else: + precision = None + factor = 1 + if context.scene.unit_settings.length_unit == "MILLIMETERS": + factor = 1000 + + return format_distance(value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True) + + @dataclass + class ToolState: + use_default_container: bool = None + snap_angle: float = None + is_input_on: bool = None + # angle_axis_start: Vector + # angle_axis_end: Vector + axis_method: str = None + plane_method: str = None + instructions: str = """TAB: Cycle Input + M: Modify Snap Point + C: Close + Backspace: Remove + X Y: Axis + Shift: Lock axis + """ + snap_info: str = None + mode: str = None + input_type: str = None + + @classmethod + def create_input_ui(cls, init_z=False, init_area=False): + return cls.PolylineUI(init_z=init_z, init_area=init_area) + + @classmethod + def create_tool_state(cls): + return cls.ToolState() + + @classmethod + def calculate_distance_and_angle(cls, context, input_ui, tool_state): + + try: + polyline_data = context.scene.BIMPolylineProperties.polyline_point + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + last_point_data = polyline_data[len(polyline_data) - 1] + except: + default_container_elevation = 0 + last_point_data = None + + snap_prop = 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)) + else: + last_point = Vector((0, 0, 0)) + + if tool_state.is_input_on: + if tool_state.use_default_container: + snap_vector = Vector( + (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), default_container_elevation) + ) + else: + snap_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)) + else: + snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + + second_to_last_point = None + if len(polyline_data) > 1: + second_to_last_point_data = polyline_data[len(polyline_data) - 2] + second_to_last_point = Vector( + (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) + ) + 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)) + + distance = (snap_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 + ) + + # Round angle to the nearest 0.05 + angle = round(angle / 0.05) * 0.05 + + if distance == 0: + angle = 0 + if input_ui: + input_ui.set_value("X", snap_vector.x) + input_ui.set_value("Y", snap_vector.y) + if input_ui.get_number_value("Z") is not None: + input_ui.set_value("Z", snap_vector.z) + + input_ui.set_value("D", distance) + input_ui.set_value("A", angle) + return + + return + + @classmethod + def calculate_area(cls, context, input_ui): + try: + polyline_data = context.scene.BIMPolylineProperties.polyline_point + except: + return input_ui + + if len(polyline_data) < 3: + return input_ui + + points = [] + for data in polyline_data: + points.append(Vector((data.x, data.y, data.z))) + + if points[0] == points[-1]: + points = points[1:] + + # TODO move this to CAD + # Calculate the normal vector of the plane formed by the first three vertices + v1, v2, v3 = points[:3] + normal = (v2 - v1).cross(v3 - v1).normalized() + + # Check if all points are coplanar + is_coplanar = True + tolerance = 1e-6 # Adjust this value as needed + for v in points: + if abs((v - v1).dot(normal)) > tolerance: + is_coplanar = False + + if is_coplanar: + area = 0 + for i in range(len(points)): + j = (i + 1) % len(points) + area += points[i].cross(points[j]).dot(normal) + + area = abs(area) / 2 + else: + area = 0 + + if input_ui.get_text_value("A") is not None: + input_ui.set_value("A", area) + return + + @classmethod + def calculate_x_y_and_z(cls, context, input_ui, tool_state): + try: + polyline_data = context.scene.BIMPolylineProperties.polyline_point + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + last_point_data = polyline_data[len(polyline_data) - 1] + last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) + except: + default_container_elevation = 0 + last_point = Vector((0, 0, 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)) + else: + snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + + if len(polyline_data) > 1: + second_to_last_point_data = polyline_data[len(polyline_data) - 2] + second_to_last_point = Vector( + (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) + ) + 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)) + + distance = input_ui.get_number_value("D") + + if distance < 0 or distance > 0: + angle = radians(input_ui.get_number_value("A")) + + rot_vector = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, angle, degrees=True) + + coords = rot_vector * distance + last_point + + x = coords[0] + y = coords[1] + z = coords[2] + if input_ui: + input_ui.set_value("X", x) + input_ui.set_value("Y", y) + if input_ui.get_number_value("Z") is not None: + input_ui.set_value("Z", z) + + return + + input_ui.set_value("X", last_point.x) + input_ui.set_value("Y", last_point.y) + if input_ui.get_number_value("Z") is not None: + input_ui.set_value("Z", last_point.z) + + return + + @classmethod + def create_wall_preview_vertices(cls, context, relating_type): + def create_bmesh_from_vertices(vertices): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in base_vertices] + if is_closed: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + new_edges.append( + bm.edges.new((new_verts[-1], new_verts[0])) + ) # Add an edge between the last an first point to make it closed. + else: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + return bm + + # Get properties from object type + layers = tool.Model.get_material_layer_parameters(relating_type) + if not layers["thickness"]: + return + thickness = layers["thickness"] + + height = float(context.scene.BIMModelProperties.extrusion_depth) + rl = float(context.scene.BIMModelProperties.rl1) + x_angle = float(context.scene.BIMModelProperties.x_angle) + angle_distortion = height * tan(x_angle) + + base_vertices = [] + top_vertices = [] + polyline_data = context.scene.BIMPolylineProperties.polyline_point + if len(polyline_data) < 2: + context.scene.BIMPolylineProperties.product_preview.clear() + return + for point in polyline_data: + base_vertices.append(Vector((point.x, point.y, point.z))) + + is_closed = False + if ( + base_vertices[0].x == base_vertices[-1].x + and base_vertices[0].y == base_vertices[-1].y + and base_vertices[0].z == base_vertices[-1].z + ): + is_closed = True + base_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. + + bm_base = create_bmesh_from_vertices(base_vertices) + bm_top = create_bmesh_from_vertices(base_vertices) + + offset_base_verts = tool.Cad.offset_edges(bm_base, thickness) + top_vertices = tool.Cad.offset_edges(bm_top, angle_distortion) + offset_top_verts = tool.Cad.offset_edges(bm_top, angle_distortion + thickness) + + if is_closed: + base_vertices.append(base_vertices[0]) + offset_base_verts.append(offset_base_verts[0]) + top_vertices.append(top_vertices[0]) + offset_top_verts.append(offset_top_verts[0]) + + if offset_base_verts is not None: + context.scene.BIMPolylineProperties.product_preview.clear() + for v in base_vertices: + prop = context.scene.BIMPolylineProperties.product_preview.add() + prop.x = v.x + prop.y = v.y + prop.z = v.z + rl + + for v in offset_base_verts[::-1]: + new_v = Vector((v.co.x, v.co.y, v.co.z)) + prop = context.scene.BIMPolylineProperties.product_preview.add() + prop.x = new_v.x + prop.y = new_v.y + prop.z = new_v.z + rl + + for v in top_vertices: + # new_v = v #+ scaled_direction + new_v = Vector((v.co.x, v.co.y, v.co.z)) + prop = context.scene.BIMPolylineProperties.product_preview.add() + prop.x = new_v.x + prop.y = new_v.y + prop.z = new_v.z + rl + height + + for v in offset_top_verts[::-1]: + new_v = Vector((v.co.x, v.co.y, v.co.z)) # + scaled_direction + prop = context.scene.BIMPolylineProperties.product_preview.add() + prop.x = new_v.x + prop.y = new_v.y + prop.z = new_v.z + rl + height + + bm_base.free() + bm_top.free() + + @classmethod + def validate_input(cls, input_number, input_type): + + grammar_imperial = """ + start: (FORMULA dim expr) | dim + dim: imperial + + FORMULA: "=" + + imperial: feet? "-"? inches? + feet: NUMBER? "-"? fraction? "'" + inches: NUMBER? "-"? fraction? "\\"" + fraction: NUMBER "/" NUMBER + + expr: (ADD | SUB) dim | (MUL | DIV) NUMBER + + NUMBER: /-?\\d+(?:\\.\\d+)?/ + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + grammar_metric = """ + start: FORMULA? dim expr? + dim: metric + + FORMULA: "=" + + metric: NUMBER + + expr: (ADD | SUB | MUL | DIV) dim + + NUMBER: /-?\\d+(?:\\.\\d+)?/ + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + class InputTransform(Transformer): + def NUMBER(self, n): + return float(n) + + def fraction(self, numbers): + return numbers[0] / numbers[1] + + def inches(self, args): + if len(args) > 1: + result = args[0] + args[1] + else: + result = args[0] + return result / 12 + + def feet(self, args): + return args[0] + + def imperial(self, args): + if len(args) > 1: + if args[0] <= 0: + result = args[0] - args[1] + else: + result = args[0] + args[1] + else: + result = args[0] + return result + + def metric(self, args): + return args[0] + + def dim(self, args): + return args[0] + + def expr(self, args): + op = args[0] + value = float(args[1]) + if op == "+": + return lambda x: x + value + elif op == "-": + return lambda x: x - value + elif op == "*": + return lambda x: x * value + elif op == "/": + return lambda x: x / value + + def FORMULA(cls, args): + return args[0] + + def start(self, args): + i = 0 + if args[0] == "=": + i += 1 + else: + if len(args) > 1: + raise ValueError("Invalid input.") + dimension = args[i] + if len(args) > i + 1: + expression = args[i + 1] + return expression(dimension) * factor + else: + return dimension * factor + + try: + if bpy.context.scene.unit_settings.system == "IMPERIAL": + parser = Lark(grammar_imperial) + factor = 0.3048 + else: + parser = Lark(grammar_metric) + factor = 1 + if bpy.context.scene.unit_settings.length_unit == "MILLIMETERS": + factor = 0.001 + + if input_type == "A": + parser = Lark(grammar_metric) + factor = 1 + + parse_tree = parser.parse(input_number) + + transformer = InputTransform() + result = transformer.transform(parse_tree) + return True, str(result) + except: + return False, "0" diff --git a/src/bonsai/bonsai/tool/profile.py b/src/bonsai/bonsai/tool/profile.py index 637e5f8fef..6a8d0a3b6d 100644 --- a/src/bonsai/bonsai/tool/profile.py +++ b/src/bonsai/bonsai/tool/profile.py @@ -16,7 +16,9 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bpy import ifcopenshell +import ifcopenshell.api.profile import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.unit @@ -38,7 +40,11 @@ class Profile(bonsai.core.tool.Profile): settings = ifcopenshell.geom.settings() settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) shape = ifcopenshell.geom.create_shape(settings, profile) + verts = shape.verts + if not verts: + raise RuntimeError("Profile shape has no vertices, it probably is invalid.") + edges = shape.edges grouped_verts = [[verts[i], verts[i + 1]] for i in range(0, len(verts), 3)] @@ -78,13 +84,136 @@ class Profile(bonsai.core.tool.Profile): return profile return None + @classmethod + def get_default_profile(cls) -> ifcopenshell.entity_instance: + """Return first found IfcProfileDef in IFC file or create a new default profile.""" + ifc_file = tool.Ifc.get() + profile = next(iter(ifc_file.by_type("IfcProfileDef")), None) + if profile: + return profile + profile = ifcopenshell.api.profile.add_parameterized_profile(ifc_file, ifc_class="IfcRectangleProfileDef") + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + profile.ProfileType = "AREA" + profile.ProfileName = "Default Profile" + profile.XDim = 0.1 / si_conversion + profile.YDim = 0.1 / si_conversion + return profile + @classmethod def get_model_profiles(cls) -> list[ifcopenshell.entity_instance]: return tool.Ifc.get().by_type("IfcProfileDef") @classmethod def duplicate_profile(cls, profile: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: - new_profile = ifcopenshell.util.element.copy_deep(tool.Ifc.get(), profile) + new_profile = ifcopenshell.api.profile.copy_profile(tool.Ifc.get(), profile) # In UI unnamed profiles are not available, so we don't handle them. new_profile.ProfileName = profile.ProfileName + "_copy" return new_profile + + @classmethod + def get_active_profile_ui(cls) -> Union[bpy.types.PropertyGroup, None]: + props = bpy.context.scene.BIMProfileProperties + index = props.active_profile_index + if len(props.profiles) > index >= 0: + return props.profiles[index] + + # Lengths are in meters. + DEFAULT_PROFILE_ATTRS = { + "IfcCircleProfileDef": { + "Radius": 0.05, + }, + # TODO: test after debug + "IfcAsymmetricIShapeProfileDef": { + "BottomFlangeWidth": 0.1, + "BottomFlangeThickness": 0.01, + "BottomFlangeFilletRadius": 0.01, + "OverallDepth": 0.1, + "WebThickness": 0.005, + "TopFlangeWidth": 0.075, + "TopFlangeThickness": 0.01, + "TopFlangeFilletRadius": 0.01, + }, + "IfcCShapeProfileDef": { + "Depth": 0.1, + "Width": 0.05, + "WallThickness": 0.01, + "Girth": 0.01, + }, + # 101.6-10.0 + "IfcCircleHollowProfileDef": { + "WallThickness": 0.01, + }, + # TODO: check shape after fixing crash + # "IfcEllipseProfileDef": { + # "SemiAxis1": 0.1, + # "SemiAxis2": 0.1, + # }, + # HEA100 + "IfcIShapeProfileDef": { + "OverallWidth": 0.1, + "OverallDepth": 0.1, + "WebThickness": 0.005, + "FlangeThickness": 0.01, + "FilletRadius": 0.01, + }, + # LNP100x10 + "IfcLShapeProfileDef": { + "Depth": 0.1, + "Thickness": 0.01, + "FilletRadius": 0.012, + "EdgeRadius": 0.01, + }, + "IfcRectangleProfileDef": { + "XDim": 0.1, + "YDim": 0.1, + }, + "IfcRoundedRectangleProfileDef": { + "RoundingRadius": 0.01, + }, + # 100-10.0 + "IfcRectangleHollowProfileDef": { + "WallThickness": 0.01, + "InnerFilletRadius": 0.01, + "OuterFilletRadius": 0.01, + }, + "IfcTShapeProfileDef": { + "Depth": 0.1, + "FlangeWidth": 0.05, + "WebThickness": 0.005, + "FlangeThickness": 0.009, + }, + "IfcTrapeziumProfileDef": { + "BottomXDim": 0.1, + "TopXDim": 0.08, + "YDim": 0.05, + "TopXOffset": 0.01, + }, + # UAP100 + "IfcUShapeProfileDef": { + "Depth": 0.1, + "FlangeWidth": 0.05, + "WebThickness": 0.005, + "FlangeThickness": 0.009, + }, + # ZNP100 + "IfcZShapeProfileDef": { + "Depth": 0.1, + "FlangeWidth": 0.05, + "WebThickness": 0.007, + "FlangeThickness": 0.01, + }, + } + + @classmethod + def set_default_profile_attrs(cls, profile: ifcopenshell.entity_instance) -> None: + """Set default profile attributes to keep profile valid.""" + class_match = False + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + for ifc_class, params in cls.DEFAULT_PROFILE_ATTRS.items(): + if profile.is_a(ifc_class): + class_match = True + for key, value in params.items(): + setattr(profile, key, value / si_conversion) + + if not class_match: + raise ValueError(f"Unable to set default profile parameters for {profile.is_a()}.") diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 5f0491a4fb..c309f88346 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -194,6 +194,7 @@ class Project(bonsai.core.tool.Project): If `filepath` was opened before, bump it in the list. """ + filepath = filepath.absolute() current_filepaths = cls.get_recent_ifc_projects() if filepath in current_filepaths: current_filepaths.remove(filepath) @@ -209,3 +210,7 @@ class Project(bonsai.core.tool.Project): @classmethod def get_appendable_asset_types(cls) -> tuple[str, ...]: return tuple(e for e in APPENDABLE_ASSET_TYPES if e != "IfcProduct") + + @classmethod + def run_root_reload_grid_decorator(cls) -> None: + tool.Root.reload_grid_decorator() diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 9cfef32dfb..dcfa111872 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -150,6 +150,27 @@ class Pset(bonsai.core.tool.Pset): tool.Ifc.schema().declaration_by_name(prop_template.PrimaryMeasureType or "IfcLabel") ) + @classmethod + def get_selected_pset_elements( + cls, obj_name: str, obj_type: tool.Ifc.OBJECT_TYPE, pset: ifcopenshell.entity_instance + ) -> list[ifcopenshell.entity_instance]: + ifc_file = tool.Ifc.get() + pset_elements = ifcopenshell.util.element.get_elements_by_pset(pset) + elements: list[ifcopenshell.entity_instance] + + if obj_type == "Object": + elements = [ + element + for obj in tool.Blender.get_selected_objects() + if (element := tool.Ifc.get_entity(obj)) and element in pset_elements + ] + else: + element_id = tool.Blender.get_obj_ifc_definition_id(obj_name, obj_type) + assert element_id + elements = [ifc_file.by_id(element_id)] + + return elements + @classmethod def import_enumerated_value_from_template( cls, prop_template: ifcopenshell.entity_instance, data: dict[str, Any], props: bpy.types.PropertyGroup @@ -276,9 +297,9 @@ class Pset(bonsai.core.tool.Pset): return bonsai.bim.schema.ifc.psetqto.get_by_name(name) @classmethod - def add_proposed_property(cls, name: str, value: Any, props: bpy.types.PropertyGroup) -> None: + def add_proposed_property(cls, name: str, value: Any, props: bpy.types.PropertyGroup) -> Union[None, str]: if props.properties.get(name): - return + return f"Property '{name}' already exists." prop = props.properties.add() prop.name = name metadata = prop.metadata @@ -306,4 +327,3 @@ class Pset(bonsai.core.tool.Pset): return value except: return value - return value diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 996a9d8c3d..e4f6db536e 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -41,7 +41,7 @@ class Raycast(bonsai.core.tool.Raycast): return all_objs @classmethod - def get_objects_2d_bounding_boxes(cls, context, obj): + def get_on_screen_2d_bounding_boxes(cls, context, obj): obj_matrix = obj.matrix_world.copy() bbox = [obj_matrix @ Vector(v) for v in obj.bound_box] @@ -56,40 +56,61 @@ class Raycast(bonsai.core.tool.Raycast): region = context.region borders = (region.width, region.height) for i, axis in enumerate(zip(*transposed_bbox)): - if all(ax < 0 or ax > borders[i] for ax in axis): # Filter only objects in viewport - return (obj, None) min_point = min(axis) max_point = max(axis) - bbox_2d.extend([min_point, max_point]) + if min_point < borders[i] and max_point > 0: + bbox_2d.extend([min_point, max_point]) + else: + return (obj, None) return (obj, bbox_2d) @classmethod - def in_view_2d_bounding_box(cls, mouse_pos, bbox): + def intersect_mouse_2d_bounding_box(cls, mouse_pos, bbox, offset=None): 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 xmin < x < xmax and ymin < y < ymax: return True else: return False @classmethod - def get_viewport_ray_data(cls, context, event): + def get_viewport_ray_data(cls, context, event, mouse_pos=None): region = context.region rv3d = context.region_data - mouse_pos = event.mouse_region_x, event.mouse_region_y + original_perspective = rv3d.view_perspective + + # TODO The raycast was working for orthographic view, but not when you are inside a camera view. This solution feels hacky, + # but it temporarily switches the perspective_matrix from camera to the perspective_matrix from ortho view. + if original_perspective == "CAMERA": + rv3d.view_perspective = "ORTHO" + if not mouse_pos: + mouse_pos = event.mouse_region_x, event.mouse_region_y view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, mouse_pos) ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, mouse_pos) ray_target = ray_origin + view_vector ray_direction = ray_target - ray_origin + if original_perspective == "CAMERA": + rv3d.view_perspective = "CAMERA" + return ray_origin, ray_target, ray_direction @classmethod - def get_object_ray_data(cls, context, event, obj_matrix): - ray_origin, ray_target, _ = cls.get_viewport_ray_data(context, event) + def get_object_ray_data(cls, context, event, obj_matrix, mouse_pos=None): + if mouse_pos: + ray_origin, ray_target, _ = cls.get_viewport_ray_data(context, event, mouse_pos) + else: + ray_origin, ray_target, _ = cls.get_viewport_ray_data(context, event) matrix_inv = obj_matrix.inverted() ray_origin_obj = matrix_inv @ ray_origin ray_target_obj = matrix_inv @ ray_target @@ -98,66 +119,76 @@ 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): - ray_origin_obj, _, ray_direction_obj = cls.get_object_ray_data(context, event, obj.matrix_world.copy()) + def obj_ray_cast(cls, context, event, obj, mouse_pos=None): + if mouse_pos: + ray_origin_obj, _, ray_direction_obj = cls.get_object_ray_data( + context, event, obj.matrix_world.copy(), mouse_pos + ) + else: + ray_origin_obj, _, ray_direction_obj = cls.get_object_ray_data(context, event, obj.matrix_world.copy()) success, location, normal, face_index = obj.ray_cast(ray_origin_obj, ray_direction_obj) if success: return location, normal, face_index else: return None, None, None - @classmethod - def ray_cast_by_proximity(cls, context, event, obj, mesh=None): + def ray_cast_by_proximity(cls, context, event, obj, face=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 + # It changes the distance and affects how the snapping point is sorted + stick_factor = 0.02 + try: loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_pos, ray_direction) except: loc = Vector((0, 0, 0)) bm = bmesh.new() - if mesh is None: # Object with faces + if face is None: # Object with faces bm.from_mesh(obj.data) - else: # Object without faces - verts = [bm.verts.new(obj.data.vertices[i].co) for i in mesh.vertices] + else: # Object without faces + verts = [bm.verts.new(obj.data.vertices[i].co) for i in face.vertices] bm.faces.new(verts) - for edge in bm.edges: - v1 = edge.verts[0].co - v2 = edge.verts[1].co - world_v1 = obj.matrix_world @ v1 - world_v2 = obj.matrix_world @ v2 - division_point = (world_v1 + world_v2) / 2 # TODO Make it work for different divisions - intersection, _ = mathutils.geometry.intersect_point_line(division_point, ray_target, loc) - distance = (division_point - intersection).length - if distance < 0.2: - points.append((division_point, "Edge Center")) - for vertex in bm.verts: - world_vertex = obj.matrix_world @ vertex.co - intersection, _ = mathutils.geometry.intersect_point_line(world_vertex, ray_target, loc) + world_vertex = obj.matrix_world.copy() @ vertex.co + intersection = tool.Cad.point_on_edge(world_vertex, (ray_target, loc)) distance = (world_vertex - intersection).length if distance < 0.2: - points.append((world_vertex, "Vertex")) + points.append([distance - stick_factor, (world_vertex, "Vertex")]) for edge in bm.edges: v1 = edge.verts[0].co v2 = edge.verts[1].co - world_v1 = obj.matrix_world @ v1 - world_v2 = obj.matrix_world @ v2 - intersection = mathutils.geometry.intersect_line_line(ray_target, loc, world_v1, world_v2) - if intersection: - distance = (intersection[0] - intersection[1]).length - if distance < 0.2: - points.append((intersection[1], "Edge")) + world_v1 = obj.matrix_world.copy() @ v1 + world_v2 = obj.matrix_world.copy() @ v2 + division_point = (world_v1 + world_v2) / 2 # TODO Make it work for different divisions - return points + intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc)) + distance = (division_point - intersection).length + if distance < 0.2: + points.append([distance, (division_point, "Edge Center")]) + intersection = tool.Cad.intersect_edges_v2((ray_target, loc), (world_v1, world_v2)) + if intersection[0]: + if tool.Cad.is_point_on_edge(intersection[1], (world_v1, world_v2)): + distance = (intersection[1] - intersection[0]).length + if distance < 0.8: + points.append([distance + 4 * stick_factor, (intersection[1], "Edge")]) + + bm.free() + snapping_points = [] + sorted_points = sorted(points) + for p in sorted_points: + snapping_points.append(p[1]) + + return snapping_points @classmethod def ray_cast_to_polyline(cls, context, event): @@ -171,12 +202,14 @@ class Raycast(bonsai.core.tool.Raycast): except: loc = Vector((0, 0, 0)) - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point + polyline_data = polyline_data[ + : len(polyline_data) - 1 + ] # It doesn't make sense to snap to the last point created polyline_points = [] for point_data in polyline_data: point = Vector((point_data.x, point_data.y, point_data.z)) - intersection, _ = mathutils.geometry.intersect_point_line(point, ray_target, loc) distance = (point - intersection).length if distance < 0.2: diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index 3b27b861c3..0522ab6ea0 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -27,6 +27,8 @@ import bonsai.core.aggregate import bonsai.core.geometry import bonsai.tool as tool from typing import Union, Optional, Any +from bonsai.bim.module.spatial.decorator import GridDecorator +from bonsai.bim.module.geometry.decorator import ItemDecorator class Root(bonsai.core.tool.Root): @@ -170,7 +172,11 @@ class Root(bonsai.core.tool.Root): @classmethod def is_containable(cls, element: ifcopenshell.entity_instance) -> bool: - if element.is_a("IfcElement") or element.is_a("IfcGrid") or element.is_a("IfcAnnotation"): + if ( + (element.is_a("IfcElement") and not element.is_a("IfcFeatureElement")) + or element.is_a("IfcGrid") + or element.is_a("IfcAnnotation") + ): return True return False @@ -195,6 +201,26 @@ class Root(bonsai.core.tool.Root): return element.is_a("IfcSpatialStructureElement") return element.is_a("IfcSpatialElement") + @classmethod + def is_grid_axis(cls, element: ifcopenshell.entity_instance) -> bool: + return element.is_a("IfcGridAxis") + + @classmethod + def reload_grid_decorator(cls) -> None: + axes = bpy.context.scene.BIMGridProperties.grid_axes + axes.clear() + for axis in tool.Ifc.get().by_type("IfcGridAxis"): + if obj := tool.Ifc.get_object(axis): + new = axes.add() + new.obj = obj + GridDecorator.install(bpy.context) + + @classmethod + def reload_item_decorator(cls) -> None: + item_objs = bpy.context.scene.BIMGeometryProperties.item_objs + item_objs.clear() + ItemDecorator.install(bpy.context) + @classmethod def link_object_data(cls, source_obj: bpy.types.Object, destination_obj: bpy.types.Object) -> None: destination_obj.data = source_obj.data diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index ef70b81a60..179ad210dd 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -23,14 +23,15 @@ class Search(bonsai.core.tool.Search): elif module == "diff": return bpy.context.scene.DiffProperties.filter_groups elif module == "drawing_include": - return bpy.context.active_object.data.BIMCameraProperties.include_filter_groups + return bpy.context.scene.camera.data.BIMCameraProperties.include_filter_groups elif module == "drawing_exclude": - return bpy.context.active_object.data.BIMCameraProperties.exclude_filter_groups + 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 + assert False, f"Unsupported module: {module}" @classmethod def import_filter_query(cls, query: str, filter_groups: bpy.types.bpy_prop_collection) -> None: diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 0b827ba684..7ffbbf6806 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -27,19 +27,24 @@ from lark import Lark, Transformer class Snap(bonsai.core.tool.Snap): - mouse_pos = None - snap_angle = None - use_default_container = False - snap_plane_method = "No Plane" + tool_state = None + snap_plane_method = None @classmethod - def set_use_default_container(cls, value=True): - cls.use_default_container = value + def set_tool_state(cls, tool_state): + cls.tool_state = tool_state @classmethod def set_snap_plane_method(cls, value=True): cls.snap_plane_method = value + @classmethod + def cycle_snap_plane_method(cls, value=True): + if cls.snap_plane_method == value: + cls.snap_plane_method = None + return + cls.snap_plane_method = value + @classmethod def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index): matrix = obj.matrix_world.copy() @@ -55,18 +60,7 @@ class Snap(bonsai.core.tool.Snap): return snap_point - @classmethod - def get_snap_points_on_polyline(cls): - snap_points = {} - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point - polyline_points = [] - for point_data in polyline_data: - point = Vector((point_data.x, point_data.y, point_data.z)) - polyline_points.append(point) - snap_points.update({tuple(point): "Polyline Point" for point in polyline_points}) - - return snap_points - + # TODO Remove this function @classmethod def select_snap_point(cls, snap_points, hit, threshold): shortest_distance = None @@ -89,11 +83,11 @@ class Snap(bonsai.core.tool.Snap): return snap_point @classmethod - def update_snaping_point(cls, snap_point, snap_type): + def update_snapping_point(cls, snap_point, snap_type): try: - snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point[0] + snap_vertex = bpy.context.scene.BIMPolylineProperties.snap_mouse_point[0] except: - snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point.add() + snap_vertex = bpy.context.scene.BIMPolylineProperties.snap_mouse_point.add() snap_vertex.x = snap_point[0] snap_vertex.y = snap_point[1] @@ -101,11 +95,15 @@ class Snap(bonsai.core.tool.Snap): snap_vertex.snap_type = snap_type @classmethod - def update_snaping_ref(cls, snap_point, snap_type): + def clear_snapping_point(cls): + bpy.context.scene.BIMPolylineProperties.snap_mouse_point.clear() + + @classmethod + def update_snapping_ref(cls, snap_point, snap_type): try: - snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_ref[0] + snap_vertex = bpy.context.scene.BIMPolylineProperties.snap_mouse_ref[0] except: - snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_ref.add() + snap_vertex = bpy.context.scene.BIMPolylineProperties.snap_mouse_ref.add() snap_vertex.x = snap_point[0] snap_vertex.y = snap_point[1] @@ -114,21 +112,21 @@ class Snap(bonsai.core.tool.Snap): @classmethod def clear_snapping_ref(cls): - bpy.context.scene.BIMModelProperties.snap_mouse_ref.clear() + bpy.context.scene.BIMPolylineProperties.snap_mouse_ref.clear() @classmethod - def insert_polyline_point(cls, input_panel): - x = float(input_panel["X"]) - y = float(input_panel["Y"]) - try: - z = float(input_panel["Z"]) - except: - z = Vector((0, 0, 0)) - d = input_panel["D"] - a = input_panel["A"] + def insert_polyline_point(cls, input_ui): + x = input_ui.get_number_value("X") + y = input_ui.get_number_value("Y") + if input_ui.get_number_value("Z") is not None: + z = input_ui.get_number_value("Z") + else: + z = 0 + d = input_ui.get_formatted_value("D") + a = input_ui.get_formatted_value("A") - snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point[0] - if cls.use_default_container: + snap_vertex = bpy.context.scene.BIMPolylineProperties.snap_mouse_point[0] + if cls.tool_state.use_default_container: z = tool.Ifc.get_object(tool.Root.get_default_container()).location.z if x is None and y is None: @@ -136,52 +134,60 @@ class Snap(bonsai.core.tool.Snap): y = snap_vertex.y z = snap_vertex.z - # Avoids creating two points at the same location - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point if polyline_data: - last_point = polyline_data[len(polyline_data) - 1] - if (x, y, z) == (round(last_point.x, 4), round(last_point.y, 4), round(last_point.z, 4)): - return + # Avoids creating two points at the same location + for point in polyline_data[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" + # Avoids creating segments smaller then 0.1. This is a limitation from create_wall_from_2_points + length = ( + Vector((x, y, z)) - Vector((polyline_data[-1].x, polyline_data[-1].y, polyline_data[-1].z)) + ).length + if round(length, 4) < 0.1: + return "Cannot create a segment smaller then 10cm" - polyline_point = bpy.context.scene.BIMModelProperties.polyline_point.add() + polyline_point = bpy.context.scene.BIMPolylineProperties.polyline_point.add() polyline_point.x = x polyline_point.y = y polyline_point.z = z - polyline_measurement = bpy.context.scene.BIMModelProperties.polyline_measurement.add() + polyline_measurement = bpy.context.scene.BIMPolylineProperties.polyline_measurement.add() polyline_measurement.dim = d polyline_measurement.angle = a polyline_measurement.position = Vector((x, y, z)) @classmethod def close_polyline(cls): - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point if len(polyline_data) > 2: first_point = polyline_data[0] - polyline_point = bpy.context.scene.BIMModelProperties.polyline_point.add() - polyline_point.x = first_point.x - polyline_point.y = first_point.y - polyline_point.z = first_point.z + last_point = polyline_data[-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 = bpy.context.scene.BIMPolylineProperties.polyline_point.add() + polyline_point.x = first_point.x + polyline_point.y = first_point.y + polyline_point.z = first_point.z @classmethod def clear_polyline(cls): - bpy.context.scene.BIMModelProperties.polyline_point.clear() - bpy.context.scene.BIMModelProperties.polyline_measurement.clear() + bpy.context.scene.BIMPolylineProperties.polyline_point.clear() + bpy.context.scene.BIMPolylineProperties.polyline_measurement.clear() @classmethod def remove_last_polyline_point(cls): - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point polyline_data.remove(len(polyline_data) - 1) - polyline_measurement = bpy.context.scene.BIMModelProperties.polyline_measurement + polyline_measurement = bpy.context.scene.BIMPolylineProperties.polyline_measurement polyline_measurement.remove(len(polyline_measurement) - 1) @classmethod - def snap_on_axis(cls, intersection, lock_axis=None): + def snap_on_axis(cls, intersection, tool_state, lock_angle=False): def create_axis_line_data(rot_mat, origin): length = 1000 direction = Vector((1, 0, 0)) - if cls.snap_plane_method == "YZ": + if tool_state.plane_method == "YZ" or (not tool_state.plane_method and tool_state.axis_method == "Z"): direction = Vector((0, 0, 1)) rot_dir = rot_mat.inverted() @ direction start = origin + rot_dir * length @@ -192,13 +198,13 @@ class Snap(bonsai.core.tool.Snap): def create_axis_rectangle_data(origin): size = 0.5 direction = Vector((1, 0, 0)) - if cls.snap_plane_method == "YZ": + 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 cls.snap_plane_method == "XY": + if tool_state.plane_method == "XY": angle = 270 else: angle = 90 @@ -210,7 +216,7 @@ class Snap(bonsai.core.tool.Snap): return (v1, v2, v3, v4) default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point if polyline_data: last_point_data = polyline_data[-1] last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) @@ -220,30 +226,28 @@ class Snap(bonsai.core.tool.Snap): # Translates intersection point based on last_point translated_intersection = intersection - last_point snap_axis = [] - if not lock_axis: - for i in range(1, 13): - angle = 30 * i + if not tool_state.snap_angle: + for i in range(1, 25): + angle = 15 * i snap_axis.append(angle) else: - snap_axis = [lock_axis] + snap_axis = [tool_state.snap_angle] pivot_axis = "Z" - if cls.snap_plane_method == "XZ": + if tool_state.plane_method == "XZ": pivot_axis = "Y" - if cls.snap_plane_method == "YZ": + if tool_state.plane_method == "YZ": pivot_axis = "X" - rectangle_data = create_axis_rectangle_data(last_point) - PolylineDecorator.set_axis_rectangle(rectangle_data) for axis in snap_axis: rot_mat = Matrix.Rotation(math.radians(360 - axis), 3, pivot_axis) start, end = create_axis_line_data(rot_mat, last_point) rot_intersection = rot_mat @ translated_intersection proximity = rot_intersection.y - if cls.snap_plane_method == "XZ": + if tool_state.plane_method == "XZ": proximity = rot_intersection.z PolylineDecorator.set_angle_axis_line(start, end) - if lock_axis: + if lock_angle: is_on_rot_axis = True else: is_on_rot_axis = abs(proximity) <= 0.15 @@ -251,7 +255,7 @@ class Snap(bonsai.core.tool.Snap): if is_on_rot_axis: # Snap to axis rot_intersection = Vector((rot_intersection.x, 0, rot_intersection.z)) - if cls.snap_plane_method == "XZ": + if tool_state.plane_method == "XZ": rot_intersection = Vector((rot_intersection.x, rot_intersection.y, 0)) # Convert it back snap_intersection = rot_mat.inverted() @ rot_intersection + last_point @@ -260,48 +264,26 @@ class Snap(bonsai.core.tool.Snap): return None, None, None, None @classmethod - def mix_snap_and_axis(cls, snap_point, axis_start, axis_end, elevation): + def mix_snap_and_axis(cls, snap_point, axis_start, axis_end): # Creates a mixed snap point between the locked axis and # the object snap - # TODO Use ALT key to give the user the option to choose between the two results. - # TODO Create decorator for this - snap_point_vector = Vector((snap_point[0].x, snap_point[0].y, snap_point[0].z)) - snap_point_axis_1 = ( - Vector((snap_point[0].x + 1000, snap_point[0].y, elevation)), - Vector((snap_point[0].x - 1000, snap_point[0].y, elevation)), - ) - snap_point_axis_2 = ( - Vector((snap_point[0].x, snap_point[0].y + 1000, elevation)), - Vector((snap_point[0].x, snap_point[0].y - 1000, elevation)), - ) - snap_angle_axis = (axis_start, axis_end) - result_1 = tool.Cad.intersect_edges(snap_angle_axis, snap_point_axis_1) - result_1 = Vector((result_1[0].x, result_1[0].y, elevation)) - distance_1 = (result_1 - snap_point_vector).length - - result_2 = tool.Cad.intersect_edges(snap_angle_axis, snap_point_axis_2) - result_2 = Vector((result_2[0].x, result_2[0].y, elevation)) - distance_2 = (result_2 - snap_point_vector).length - - if distance_1 < distance_2: - best_result = result_1 - else: - best_result = result_2 - - return best_result, "Mix" - - # except Exception as e: - # cls.update_snaping_point(snap_point[0], snap_point[1]) + 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 for i in intersections if i is not None) + if sorted_intersections[0]: + return sorted_intersections[0], "Mix" @classmethod - def detect_snapping_points(cls, context, event, objs_2d_bbox): - region = context.region + def detect_snapping_points(cls, context, event, objs_2d_bbox, tool_state): rv3d = context.region_data - cls.mouse_pos = event.mouse_region_x, event.mouse_region_y + space = context.space_data + mouse_pos = event.mouse_region_x, event.mouse_region_y detected_snaps = [] - offset = 15 snap_threshold = 0.3 + offset = 10 mouse_offset = ( (-offset, offset), (0, offset), @@ -317,14 +299,18 @@ class Snap(bonsai.core.tool.Snap): def select_plane_method(): if not last_polyline_point: plane_origin = Vector((0, 0, 0)) + plane_normal = Vector((0, 0, 1)) - if cls.snap_plane_method == "No Plane": + if not tool_state.plane_method: camera_rotation = rv3d.view_rotation plane_origin = Vector((0, 0, 0)) - plane_normal = Vector((0, 1, 1, 1)) * Vector((camera_rotation)) + view_direction = Vector((0, 0, -1)) @ camera_rotation.to_matrix().transposed() + plane_normal = view_direction.normalized() - if cls.snap_plane_method == "XY": - if cls.use_default_container: + if tool_state.plane_method == "XY" or ( + not tool_state.plane_method and tool_state.axis_method in {"X", "Y"} + ): + if cls.tool_state.use_default_container: plane_origin = Vector((0, 0, elevation)) elif not last_polyline_point: plane_origin = Vector((0, 0, 0)) @@ -332,35 +318,37 @@ class Snap(bonsai.core.tool.Snap): plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((0, 0, 1)) - elif cls.snap_plane_method == "XZ": + elif tool_state.plane_method == "XZ" or (not tool_state.plane_method and tool_state.axis_method == "Z"): if last_polyline_point: plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((0, 1, 0)) - elif cls.snap_plane_method == "YZ": + elif tool_state.plane_method == "YZ": if last_polyline_point: plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((1, 0, 0)) return plane_origin, plane_normal - def cast_rays_and_get_best_object(objs_to_raycast): + 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: + if obj.type != "MESH": + continue 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 = cls.mouse_pos + original_mouse_pos = mouse_pos for value in mouse_offset: - cls.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 = 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 - cls.mouse_pos = original_mouse_pos + mouse_pos = original_mouse_pos if hit is not None: hit_world = obj.original.matrix_world @ hit @@ -377,29 +365,37 @@ class Snap(bonsai.core.tool.Snap): 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 == "MESH" and bbox_2d: - if tool.Raycast.in_view_2d_bounding_box(cls.mouse_pos, bbox_2d): - objs_to_raycast.append(obj) - # Obj - snap_obj, hit, face_index = cast_rays_and_get_best_object(objs_to_raycast) + if obj.type in {"MESH", "EMPTY"} and bbox_2d: + if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d, offset): + if space.local_view: + if obj.local_view_get(context.space_data): + objs_to_raycast.append(obj) + else: + objs_to_raycast.append(obj) + # Obj + snap_obj, hit, face_index = cast_rays_and_get_best_object(objs_to_raycast, mouse_pos) if hit is not None: detected_snaps.append({"Object": (snap_obj, hit, face_index)}) # Edge-Vertex for obj in objs_to_raycast: - options = tool.Raycast.ray_cast_by_proximity(context, event, obj) - snap_obj = obj - if options: - detected_snaps.append({"Edge-Vertex": (snap_obj, options)}) - break + if obj.type == "MESH": + if len(obj.data.polygons) == 0: + options = tool.Raycast.ray_cast_by_proximity(context, event, obj) + snap_obj = obj + if options: + detected_snaps.append({"Edge-Vertex": (snap_obj, options)}) + break + if obj.type == "EMPTY": + snap_point = [(obj.location, "Vertex")] + detected_snaps.append({"Edge-Vertex": (obj, snap_point)}) # Polyline try: - polyline_data = bpy.context.scene.BIMModelProperties.polyline_point + polyline_data = bpy.context.scene.BIMPolylineProperties.polyline_point last_polyline_point = polyline_data[len(polyline_data) - 1] except: last_polyline_point = None @@ -412,29 +408,53 @@ class Snap(bonsai.core.tool.Snap): elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z plane_origin, plane_normal = select_plane_method() - PolylineDecorator.set_plane(plane_origin, plane_normal) intersection = tool.Raycast.ray_cast_to_plane(context, event, plane_origin, plane_normal) axis_start = None axis_end = None - if cls.snap_plane_method in {"XY", "XZ", "YZ"}: - rot_intersection, snap_angle, axis_start, axis_end = cls.snap_on_axis(intersection, cls.snap_angle) - if rot_intersection: - detected_snaps.append({"Axis": rot_intersection}) + + # 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": + tool_state.snap_angle = 180 + if tool_state.axis_method == "Y": + tool_state.snap_angle = 90 + if tool_state.axis_method == "Z": + 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) + + 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": + tool_state.snap_angle = 90 + if tool_state.plane_method in {"YZ"} and tool_state.axis_method == "Z": + tool_state.snap_angle = 180 + if tool_state.plane_method in {"XZ"} and tool_state.axis_method == "Z": + tool_state.snap_angle = 90 + if event.shift 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) + else: + rot_intersection, tool_state.snap_angle, axis_start, axis_end = cls.snap_on_axis( + intersection, tool_state, False + ) + if rot_intersection: + detected_snaps.append({"Axis": (rot_intersection, axis_start, axis_end)}) detected_snaps.append({"Plane": intersection}) return detected_snaps - @classmethod - def select_snapping_points(cls, context, event, detected_snaps): + def select_snapping_points(cls, context, event, tool_state, detected_snaps): snapping_points = [] for origin in detected_snaps: - if "Object" in list(origin.keys()): - snap_obj, hit, face_index = origin['Object'] - + snap_obj, hit, face_index = origin["Object"] matrix = snap_obj.matrix_world.copy() face = snap_obj.data.polygons[face_index] verts = [] @@ -448,170 +468,49 @@ class Snap(bonsai.core.tool.Snap): for op in options: snapping_points.append(op) - cls.update_snaping_point(snapping_points[0][0], snapping_points[0][1]) + break - return snapping_points - - elif "Edge-Vertex" in list(origin.keys()): - snap_obj, options = origin['Edge-Vertex'] + if "Edge-Vertex" in list(origin.keys()): + snap_obj, options = origin["Edge-Vertex"] for op in options: snapping_points.append(op) + break - cls.update_snaping_point(snapping_points[0][0], snapping_points[0][1]) - return snapping_points - - elif "Polyline" in list(origin.keys()): - options = origin['Polyline'] + if "Polyline" in list(origin.keys()): + options = origin["Polyline"] for op in options: snapping_points.append(op) + break - cls.update_snaping_point(snapping_points[0][0], snapping_points[0][1]) - return snapping_points + for origin in detected_snaps: + if "Axis" in list(origin.keys()): + intersection = origin["Axis"] + axis_start = intersection[1] + axis_end = intersection[2] + snapping_points.append((intersection[0], "Axis")) - elif "Axis" in list(origin.keys()): - intersection = origin['Axis'] - snapping_points.append((intersection, "Axis")) - - cls.update_snaping_point(snapping_points[0][0], snapping_points[0][1]) - return snapping_points - - elif "Plane" in list(origin.keys()): - intersection = origin['Plane'] + if "Plane" in list(origin.keys()): + intersection = origin["Plane"] snapping_points.append((intersection, "Plane")) - cls.update_snaping_point(snapping_points[0][0], snapping_points[0][1]) - return snapping_points + # Make Axis first priority + if event.shift or tool_state.axis_method in {"X", "Y", "Z"}: + cls.update_snapping_ref(snapping_points[0][0], snapping_points[0][1]) + for point in snapping_points: + if point[1] == "Axis": + if snapping_points[0][1] not in {"Axis", "Plane"}: + mixed_snap = cls.mix_snap_and_axis(snapping_points[0], axis_start, axis_end) + snapping_points.insert(0, (mixed_snap[0], mixed_snap[1])) + cls.update_snapping_point(mixed_snap[0], mixed_snap[1]) + return snapping_points + cls.update_snapping_point(point[0], point[1]) + return snapping_points + + cls.update_snapping_point(snapping_points[0][0], snapping_points[0][1]) + return snapping_points - @classmethod def modify_snapping_point_selection(cls, snapping_points): shifted_list = snapping_points[1:] + snapping_points[:1] - cls.update_snaping_point(shifted_list[0][0], shifted_list[0][1]) + cls.update_snapping_point(shifted_list[0][0], shifted_list[0][1]) return shifted_list - - - @classmethod - def validate_input(cls, input_number, input_type): - - grammar_imperial = """ - start: FORMULA? dim expr? - dim: imperial - - FORMULA: "=" - - imperial: feet? "-"? inches? - feet: NUMBER? " "? fraction? "'" - inches: NUMBER? " "? fraction? "\\"" - fraction: NUMBER "/" NUMBER - - expr: (ADD | SUB) dim | (MUL | DIV) NUMBER - - NUMBER: /-?\\d+(?:\\.\\d+)?/ - ADD: "+" - SUB: "-" - MUL: "*" - DIV: "/" - - %ignore " " - """ - - grammar_metric = """ - start: FORMULA? dim expr? - dim: metric - - FORMULA: "=" - - metric: NUMBER - - expr: (ADD | SUB | MUL | DIV) dim - - NUMBER: /-?\\d+(?:\\.\\d+)?/ - ADD: "+" - SUB: "-" - MUL: "*" - DIV: "/" - - %ignore " " - """ - - class InputTransform(Transformer): - def NUMBER(self, n): - return float(n) - - def fraction(self, numbers): - return numbers[0] / numbers[1] - - def inches(self, args): - if len(args) > 1: - result = (args[0] + args[1]) - else: - result = args[0] - return result / 12 - - def feet(self, args): - return args[0] - - def imperial(self, args): - if len(args) > 1: - result = (args[0] + args[1]) - else: - result = args[0] - return result - - def metric(self, args): - return args[0] - - def dim(self, args): - return args[0] - - def expr(self, args): - op = args[0] - value = float(args[1]) - if op == "+": - return lambda x: x + value - elif op == "-": - return lambda x: x - value - elif op == "*": - return lambda x: x * value - elif op == "/": - return lambda x: x / value - - def FORMULA(cls, args): - return args[0] - - def start(self, args): - i = 0 - if args[0] == "=": - i += 1 - else: - if len(args) > 1: - raise ValueError("Invalid input.") - dimension = args[i] - if len(args) > i+1: - expression = args[i + 1] - return expression(dimension) * factor - else: - return dimension * factor - - - try: - if bpy.context.scene.unit_settings.system == 'IMPERIAL': - parser = Lark(grammar_imperial) - factor = 0.3048 - else: - parser = Lark(grammar_metric) - factor = 1 - if bpy.context.scene.unit_settings.length_unit == 'MILLIMETERS': - factor = 0.001 - - if input_type == 'A': - parser = Lark(grammar_metric) - factor = 1 - - parse_tree = parser.parse(input_number) - - transformer = InputTransform() - result = transformer.transform(parse_tree) - return True, str(result) - except: - return False, "0" diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 499508e9bd..258d494224 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -26,6 +26,7 @@ import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.representation +import ifcopenshell.util.classification import ifcopenshell.util.shape_builder import ifcopenshell.util.type import ifcopenshell.util.unit @@ -39,14 +40,15 @@ import json from math import pi from mathutils import Vector, Matrix from shapely import Polygon -from typing import Generator, Optional, Union, Literal, List +from typing import Generator, Optional, Union, Literal, List, Any, Iterable +from collections import defaultdict +from natsort import natsorted class Spatial(bonsai.core.tool.Spatial): @classmethod def can_contain(cls, container: ifcopenshell.entity_instance, element_obj: Union[bpy.types.Object, None]) -> bool: - element = tool.Ifc.get_entity(element_obj) - if not element: + if not (element := tool.Ifc.get_entity(element_obj)): return False if tool.Ifc.get_schema() == "IFC2X3": if not container.is_a("IfcSpatialStructureElement"): @@ -139,7 +141,7 @@ class Spatial(bonsai.core.tool.Spatial): target_obj.matrix_world = relative_to_obj.matrix_world @ matrix @classmethod - def select_products(cls, products: list[ifcopenshell.entity_instance], unhide: bool = False) -> None: + def select_products(cls, products: Iterable[ifcopenshell.entity_instance], unhide: bool = False) -> None: bpy.ops.object.select_all(action="DESELECT") for product in products: obj = tool.Ifc.get_object(product) @@ -198,15 +200,54 @@ class Spatial(bonsai.core.tool.Spatial): src_obj.location.xy = destination_obj.location.xy @classmethod - def load_contained_elements(cls) -> None: + def get_container_elements_grouped_by_classification(cls, container: ifcopenshell.entity_instance) -> dict: props = bpy.context.scene.BIMSpatialDecompositionProperties - props.elements.clear() - if not (container := props.active_container): - return - - container = tool.Ifc.get().by_id(container.ifc_definition_id) - results = {} + if props.should_include_children: + elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=True) + else: + elements = ifcopenshell.util.element.get_contained(container) + flat_results: dict[str, Any] = {} + reference_names: dict[str, str] = {} + for element in elements: + if element.is_a("IfcOpeningElement") or tool.Root.is_spatial_element(element): + continue + references = ifcopenshell.util.classification.get_references(element) + if not references: + flat_results.setdefault("Unclassified", []).append(element) + else: + for reference in references: + identification = reference[1] or "" + reference_names[identification] = reference[2] + flat_results.setdefault(identification, []).append(element) + + for flat_key in sorted(flat_results.keys()): + current_results = results + + while True: + has_parent = None + for key in current_results: + if flat_key.startswith(key): + has_parent = True + new_current_results = current_results[key]["children"] + break + if has_parent: + current_results = new_current_results + else: + break + + current_results[flat_key] = { + "Name": "Unclassified" if flat_key == "Unclassified" else reference_names[flat_key], + "elements": flat_results[flat_key], + "children": {}, + } + + return results + + @classmethod + def get_container_elements_grouped_by_type(cls, container: ifcopenshell.entity_instance) -> dict: + props = bpy.context.scene.BIMSpatialDecompositionProperties + results: defaultdict[str, dict[int, Any]] = defaultdict(dict) if props.should_include_children: elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=True) else: @@ -218,41 +259,156 @@ class Spatial(bonsai.core.tool.Spatial): ifc_class = element.is_a() ifc_definition_id = element_type.id() if element_type else 0 type_name = element_type.Name or "Unnamed" if element_type else f"Untyped {element.is_a()}" - results.setdefault(ifc_class, {}).setdefault(ifc_definition_id, {"total": 0, "type_name": type_name}) - results[ifc_class][ifc_definition_id]["total"] += 1 + class_data = results.setdefault(ifc_class, {}) + type_data = class_data.setdefault(ifc_definition_id, {"type_name": type_name, "elements": []}) + type_data["elements"].append(element) + return results + + @classmethod + def load_contained_elements(cls) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + props.elements.clear() + if not (container := props.active_container): + return + + container = tool.Ifc.get().by_id(container.ifc_definition_id) + if props.element_mode == "TYPE": + cls.load_contained_elements_by_type(container) + elif props.element_mode == "DECOMPOSITION": + cls.load_contained_elements_by_decomposition(container) + elif props.element_mode == "CLASSIFICATION": + cls.load_contained_elements_by_classification(container) + + @classmethod + def load_contained_elements_by_type(cls, container: ifcopenshell.entity_instance) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + results = cls.get_container_elements_grouped_by_type(container) + + expanded_elements = json.loads(props.expanded_elements) + expanded_classes = expanded_elements.get("CLASS", []) + expanded_ifc_ids = expanded_elements.get("IFC_ID", []) + expanded_untyped = expanded_elements.get("UNTYPED_CLASSES", []) + expanded_classes_r = expanded_elements.get("CLASS_R", []) total_elements = 0 for ifc_class in sorted(results.keys()): new = props.elements.add() new.name = ifc_class - new.is_class = True + new.type = "CLASS" + new.has_children = True + class_is_expanded = ifc_class in expanded_classes + class_is_expanded_r = ifc_class in expanded_classes_r + new.is_expanded = class_is_expanded or class_is_expanded_r total = 0 for ifc_definition_id in sorted( results[ifc_class].keys(), key=lambda x: results[ifc_class][x]["type_name"] ): - new2 = props.elements.add() - new2.is_type = True - new2.name = results[ifc_class][ifc_definition_id]["type_name"] - new2.ifc_class = ifc_class - new2.total = results[ifc_class][ifc_definition_id]["total"] - new2.ifc_definition_id = ifc_definition_id - total += new2.total + type_data = results[ifc_class][ifc_definition_id] + total2 = len(type_data["elements"]) + if new.is_expanded: + new2 = props.elements.add() + new2.type = "TYPE" + new2.has_children = True + new2.level = 1 + new2.name = type_data["type_name"] + new2.ifc_class = ifc_class + new2.total = total2 + new2.ifc_definition_id = ifc_definition_id + if ifc_definition_id == 0: + type_is_expanded = ifc_class in expanded_untyped + else: + type_is_expanded = ifc_definition_id in expanded_ifc_ids + new2.is_expanded = type_is_expanded or class_is_expanded_r + + if new2.is_expanded: + for element in type_data["elements"]: + occurrence = props.elements.add() + occurrence.name = element.Name or "Unnamed" + occurrence.level = 2 + occurrence.ifc_definition_id = element.id() + occurrence.type = "OCCURRENCE" + + total += total2 new.total = total total_elements += total props.total_elements = total_elements + @classmethod + def load_contained_elements_by_decomposition(cls, container: ifcopenshell.entity_instance) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + expanded_elements = json.loads(props.expanded_elements) + expanded_ifc_ids = expanded_elements.get("IFC_ID", []) + + def add_elements(elements, level=0): + for element in sorted(elements, key=lambda x: f"{x.is_a()}/{x.Name or 'Unnamed'}"): + ifc_definition_id = element.id() + new = props.elements.add() + new.name = f"{element.is_a()}/{element.Name or 'Unnamed'}" + new.level = level + new.ifc_definition_id = ifc_definition_id + new.type = "OCCURRENCE" + children = [ + e + for e in ifcopenshell.util.element.get_decomposition(element, is_recursive=False) + if not e.is_a("IfcFeatureElement") + ] + if children: + new.has_children = True + new.total = len(children) + if ifc_definition_id in expanded_ifc_ids: + new.is_expanded = True + add_elements(children, level=level + 1) + + elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=False) + add_elements(elements) + + @classmethod + def load_contained_elements_by_classification(cls, container: ifcopenshell.entity_instance) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + expanded_elements = json.loads(props.expanded_elements) + expanded_classifications = expanded_elements.get("CLASSIFICATION", []) + expanded_classifications_r = expanded_elements.get("CLASSIFICATION_R", []) + + def add_elements(results, level=0): + for identification in sorted(results.keys()): + data = results[identification] + new = props.elements.add() + new.name = f"{identification}:{data['Name']}" + new.type = "CLASSIFICATION" + new.identification = identification + new.has_children = True + new.level = level + + is_expanded = identification in expanded_classifications + is_expanded_r = any([c for c in expanded_classifications_r if identification.startswith(c)]) + + if is_expanded or is_expanded_r: + new.is_expanded = True + add_elements(data["children"], level=level + 1) + + for element in sorted(data["elements"], key=lambda x: f"{x.is_a()}/{x.Name or 'Unnamed'}"): + new2 = props.elements.add() + new2.name = f"{element.is_a()}/{element.Name or 'Unnamed'}" + new2.type = "OCCURRENCE" + new2.level = level + 1 + new2.ifc_definition_id = element.id() + + results = cls.get_container_elements_grouped_by_classification(container) + add_elements(results) + @classmethod def filter_elements( cls, elements: List[ifcopenshell.entity_instance], ifc_class: str | None, relating_type: ifcopenshell.entity_instance | None, - is_untyped: bool | None, + is_untyped: bool, keyword: str | None, ) -> filter[ifcopenshell.entity_instance]: keyword = keyword.lower() if keyword else keyword - def filter_element(element): + + def filter_element(element: ifcopenshell.entity_instance) -> bool: if ifc_class: if not element.is_a(ifc_class): return False @@ -268,6 +424,7 @@ class Spatial(bonsai.core.tool.Spatial): if keyword not in f"{element.is_a()} {type_name}".lower(): return False return True + return filter(filter_element, elements) @classmethod @@ -291,11 +448,15 @@ class Spatial(bonsai.core.tool.Spatial): new.long_name = element.LongName or "" if not element.is_a("IfcProject"): ifc_file = tool.Ifc.get() - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - new.elevation = ifcopenshell.util.placement.get_storey_elevation(element) * si_conversion + new.elevation = str(ifcopenshell.util.placement.get_storey_elevation(element)) new.is_expanded = element.id() not in cls.contracted_containers new.level_index = level_index children = ifcopenshell.util.element.get_parts(element) + if children: + children = natsorted( + children, + key=lambda element: (ifcopenshell.util.placement.get_storey_elevation(element), element.Name), + ) new.has_children = bool(children) new.ifc_definition_id = element.id() if new.is_expanded: @@ -336,6 +497,123 @@ class Spatial(bonsai.core.tool.Spatial): 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 + if props.element_mode == "TYPE": + cls.toggle_container_element_by_type(element_index, is_recursive) + elif props.element_mode == "DECOMPOSITION": + cls.toggle_container_element_by_decomposition(element_index, is_recursive) + elif props.element_mode == "CLASSIFICATION": + cls.toggle_container_element_by_classification(element_index, is_recursive) + + @classmethod + def toggle_container_element_by_type(cls, element_index: int, is_recursive: bool) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + expanded_elements: dict[str, list[Union[str, int]]] = json.loads(props.expanded_elements) + + element = props.elements[element_index] + if element.type == "CLASS": + element_type = "CLASS" + filtered_item = element.name + elif element.type == "TYPE": + if element.ifc_definition_id == 0: + element_type = "UNTYPED_CLASSES" + filtered_item = element.ifc_class + else: + element_type = "IFC_ID" + filtered_item = element.ifc_definition_id + else: + return + + expanded_elements_list: list[Union[str, int]] = expanded_elements.setdefault(element_type, []) + if filtered_item in expanded_elements_list: + expanded_elements_list.remove(filtered_item) + should_expand = False + else: + expanded_elements_list.append(filtered_item) + should_expand = True + + if is_recursive and element.type == "CLASS": + container = tool.Ifc.get().by_id(props.active_container.ifc_definition_id) + results = cls.get_container_elements_grouped_by_type(container) + for ifc_class in results.keys(): + if ifc_class != element.name: + continue + for ifc_definition_id in results[ifc_class].keys(): + if ifc_definition_id == 0: + element_type = "UNTYPED_CLASSES" + filtered_item = ifc_class + else: + element_type = "IFC_ID" + filtered_item = ifc_definition_id + + expanded_elements_list = expanded_elements.setdefault(element_type, []) + if should_expand is False and filtered_item in expanded_elements_list: + expanded_elements_list.remove(filtered_item) + elif should_expand is True and filtered_item not in expanded_elements_list: + expanded_elements_list.append(filtered_item) + + props.expanded_elements = json.dumps(expanded_elements) + + @classmethod + def toggle_container_element_by_decomposition(cls, element_index: int, is_recursive: bool) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + 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", []) + ifc_definition_id = element.ifc_definition_id + if ifc_definition_id in expanded_elements_list: + expanded_elements_list.remove(ifc_definition_id) + should_expand = False + else: + expanded_elements_list.append(ifc_definition_id) + should_expand = True + + if is_recursive: + queue = [tool.Ifc.get().by_id(ifc_definition_id)] + while queue: + element = queue.pop() + children = [ + e + for e in ifcopenshell.util.element.get_decomposition(element, is_recursive=False) + if not e.is_a("IfcFeatureElement") + ] + ifc_definition_id = element.id() + if children: + if should_expand is False and ifc_definition_id in expanded_elements_list: + expanded_elements_list.remove(ifc_definition_id) + elif should_expand is True and ifc_definition_id not in expanded_elements_list: + expanded_elements_list.append(ifc_definition_id) + queue.extend(children) + + props.expanded_elements = json.dumps(expanded_elements) + + @classmethod + def toggle_container_element_by_classification(cls, element_index: int, is_recursive: bool) -> None: + props = bpy.context.scene.BIMSpatialDecompositionProperties + expanded_elements: dict[str, list[str]] = json.loads(props.expanded_elements) + expanded_elements_list: list[str] = expanded_elements.setdefault("CLASSIFICATION", []) + + element = props.elements[element_index] + identification = element.identification + + if identification in expanded_elements_list: + expanded_elements_list.remove(identification) + should_expand = False + else: + expanded_elements_list.append(identification) + should_expand = True + + if is_recursive: + expanded_elements_list = expanded_elements.setdefault("CLASSIFICATION_R", []) + if should_expand is False and identification in expanded_elements_list: + expanded_elements_list.remove(identification) + elif should_expand is True and identification not in expanded_elements_list: + expanded_elements_list.append(identification) + + props.expanded_elements = json.dumps(expanded_elements) + # HERE STARTS SPATIAL TOOL @classmethod diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 6f1690a7e5..796334756e 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -21,6 +21,7 @@ import numpy as np import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.representation +import bonsai.core.style import bonsai.core.tool import bonsai.tool as tool import bonsai.bim.helper @@ -695,3 +696,20 @@ class Style(bonsai.core.tool.Style): blender_material = tool.Ifc.get_object(style) # Will implicitly update the style name using handler. blender_material.name = name + + @classmethod + def purge_unused_styles(cls) -> int: + """Purge unused styles (and related Blender materials). + + Note that Styles UI should be updated manually after using this method. + """ + + ifc_file = tool.Ifc.get() + elements = ifc_file.by_type("IfcPresentationStyle") + i = 0 + for element in elements: + if ifc_file.get_total_inverses(element) != 0: + continue + bonsai.core.style.remove_style(tool.Ifc, tool.Style, element, reload_styles_ui=False) + i += 1 + return i diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index ebb1c3fd6d..c3c9270f3d 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -16,11 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from ifcopenshell.util.classification import get_classification_data, get_references +from ifcopenshell.util.selector import filter_elements +import ifcopenshell.util.cost import bpy from bonsai.bim.module.web.data import WebData +from ifcopenshell.util.element import get_psets, get_type, has_property import bonsai.core.tool import bonsai.tool as tool import ifcopenshell.api.sequence +import ifcopenshell.api.cost from typing import Any, Dict, Optional import time import socket @@ -36,6 +41,10 @@ import queue import json from time import sleep from pathlib import Path +import bonsai.core.sequence +import bonsai.core.cost +from ifc5d.ifc2json import ifc5D2json +from bonsai.bim.ifc import IfcStore sio = None ws_process = None @@ -310,6 +319,8 @@ class Web(bonsai.core.tool.Web): elif operator["sourcePage"] == "demo": message = operator["operator"]["message"] print(f"Message from demo page: {message}") + elif operator["sourcePage"] == "cost": + cls.handle_cost_operator(operator["operator"]) return 1.0 @classmethod @@ -328,6 +339,353 @@ class Web(bonsai.core.tool.Web): bpy.context.view_layer.objects.active = obj obj.select_set(True) + @classmethod + def handle_cost_operator(cls, operator_data: dict) -> None: + """ + this method handles the Cost page operators. + + Args: + operator_data (dict): A dictionary containing the operator data. + """ + + ifc_file = tool.Ifc.get() + if operator_data["type"] == "getPredefinedTypes": + cls.send_webui_data( + data=ifcopenshell.util.cost.get_cost_schedule_types(ifc_file), + data_key="predefined_types", + event="predefined_types", + ) + if operator_data["type"] == "addCostSchedule": + bonsai.core.cost.add_cost_schedule( + tool.Ifc, name=operator_data["name"], predefined_type=operator_data["predefinedType"] + ) + cost_schedules_json = [cs.get_info(recursive=True) for cs in ifc_file.by_type("IfcCostSchedule")] + currency = tool.Cost.currency() + cls.send_webui_data( + data={"cost_schedules": cost_schedules_json, "currency": currency}, + data_key="cost_schedules", + event="cost_schedules", + ) + if operator_data["type"] == "getCostSchedules": + cost_schedules_json = [cs.get_info(recursive=True) for cs in ifc_file.by_type("IfcCostSchedule")] + currency = tool.Cost.currency() + cls.send_webui_data( + data={"cost_schedules": cost_schedules_json, "currency": currency}, + data_key="cost_schedules", + event="cost_schedules", + ) + if operator_data["type"] == "loadCostSchedule": + cost_schedule = ifc_file.by_id(operator_data["costScheduleId"]) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "selectAssignedElements": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + products = tool.Cost.get_cost_item_products(cost_item, is_deep=True) + tool.Spatial.select_products(products, unhide=True) + if operator_data["type"] == "addSummaryCostItem": + cost_schedule = ifc_file.by_id(operator_data["costScheduleId"]) + tool.Ifc.run("cost.add_cost_item", cost_schedule=cost_schedule) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "addCostItem": + if not operator_data["costItemId"]: + return + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + tool.Ifc.run("cost.add_cost_item", cost_item=cost_item) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "deleteCostItem": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + tool.Ifc.run("cost.remove_cost_item", cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "duplicateCostItem": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + tool.Ifc.run("cost.copy_cost_item", cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "editCostItemName": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + tool.Ifc.run("cost.edit_cost_item", cost_item=cost_item, attributes={"Name": operator_data["name"]}) + if operator_data["type"] == "enableEditingCostValues": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + cost_values = ifcopenshell.util.cost.get_cost_values(cost_item) + cls.send_webui_data( + data={"cost_values": cost_values, "cost_item_id": operator_data["costItemId"]}, + data_key="cost_values", + event="cost_values", + ) + if operator_data["type"] == "addCostValue": + value = ifcopenshell.api.cost.add_cost_value( + ifc_file, + parent=ifc_file.by_id(operator_data["costItemId"]), + ) + cls.send_webui_data( + data={"cost_value_id": value.id(), "cost_item_id": operator_data["costItemId"]}, + data_key="cost_value", + event="cost_value", + ) + if operator_data["type"] == "addSumCostValue": + cost_item = ifc_file.by_id(operator_data["costItemId"]) + value = tool.Ifc.run("cost.add_cost_value", parent=cost_item) + tool.Ifc.run("cost.edit_cost_value", cost_value=value, attributes={"Category": "*"}) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + + if operator_data["type"] == "deleteCostValue": + cost_item = ifc_file.by_id(operator_data["costItemId"]) + cost_value = ifc_file.by_id(operator_data["costValueId"]) + tool.Ifc.run("cost.remove_cost_value", parent=cost_item, cost_value=cost_value) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "editCostValues": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + for value_data in operator_data["costValues"] or []: + value = ifc_file.by_id(value_data["id"]) + attributes = {} + if value_data["costType"] == "FIXED": + attributes = {"AppliedValue": value_data["appliedValue"], "Category": None} + elif value_data["costType"] == "CATEGORY": + attributes = { + "AppliedValue": value_data["appliedValue"], + "Category": value_data["costCategory"], + } + elif value_data["costType"] == "SUM": + attributes = {"Category": "*"} + if value_data["unitBasisValue"]: + attributes["UnitBasis"] = { + "ValueComponent": value_data["unitBasisValue"], + "UnitComponent": ifc_file.by_id(value_data["unitComponent"]), + } + ifcopenshell.api.cost.edit_cost_value(file=ifc_file, cost_value=value, attributes=attributes) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "addProductAssignments": + prop_name = operator_data["propName"] + if prop_name == "count": + prop_name = "" + bonsai.core.cost.assign_cost_item_quantity( + tool.Ifc, + tool.Cost, + cost_item=ifc_file.by_id(operator_data["costItemId"]), + related_object_type="PRODUCT", + prop_name=prop_name, + ) + cost_schedule = tool.Cost.get_cost_schedule(cost_item=tool.Ifc.get().by_id(operator_data["costItemId"])) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "enableEditingQuantities": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cls.load_cost_item_quantities_ui(cost_item) + if operator_data["type"] == "AddCostItemQuantity": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + tool.Ifc.run("cost.add_cost_item_quantity", cost_item=cost_item, ifc_class=operator_data["ifcClass"]) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.load_cost_item_quantities_ui(cost_item) + if operator_data["type"] == "editCostItemQuantity": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + physical_quantity = tool.Ifc.get().by_id(operator_data["quantityId"]) + tool.Ifc.run( + "cost.edit_cost_item_quantity", + physical_quantity=physical_quantity, + attributes=operator_data["attributes"], + ) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.load_cost_item_quantities_ui(cost_item) + if operator_data["type"] == "deleteCostItemQuantity": + cost_item = ifc_file.by_id(operator_data["costItemId"]) + physical_quantity = ifc_file.by_id(operator_data["quantityId"]) + tool.Ifc.run("cost.remove_cost_item_quantity", cost_item=cost_item, physical_quantity=physical_quantity) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.load_cost_item_quantities_ui(cost_item) + if operator_data["type"] == "enableEditingClassification": + cost_item = ifc_file.by_id(operator_data["costItemId"]) + cost_classifications = [] + for reference in get_references(cost_item): + cost_classification_data = reference.get_info() + del cost_classification_data["ReferencedSource"] + cost_classifications.append(cost_classification_data) + classification_data, classification_name = get_classification_data(IfcStore.classification_file) + data = { + "classification_data": classification_data, + "classification_name": classification_name, + "cost_item_id": cost_item.id(), + "cost_classifications": cost_classifications, + } + cls.send_webui_data( + data=data, + data_key="classification", + event="classification", + ) + if operator_data["type"] == "removeClassificationReference": + cost_item = ifc_file.by_id(operator_data["costItemId"]) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + reference = ifc_file.by_id(operator_data["classificationId"]) + tool.Ifc.run("classification.remove_reference", products=[cost_item], reference=reference) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "addClassificationReference": + cost_item = ifc_file.by_id(operator_data["costItemId"]) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + classification = None + classification_name = operator_data["classificationName"] + for element in tool.Ifc.get().by_type("IfcClassification"): + if element.Name == classification_name: + classification = element + break + reference = IfcStore.classification_file.by_id(operator_data["classificationId"]) + tool.Ifc.run( + "classification.add_reference", products=[cost_item], reference=reference, classification=classification + ) + cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "assignFromQuery": + prop_name = operator_data["propName"] + cost_item = ifc_file.by_id(operator_data["costItemId"]) + results = [] + try: + results = filter_elements(ifc_file, operator_data["query"]) + except Exception as e: + cls.send_webui_data( + data={ + "error": "Your query is invalid. Please check the syntax and try again.", + "container": "query-quantities-section-" + str(operator_data["costItemId"]), + }, + data_key="message", + event="message", + ) + return + if not results: + cls.send_webui_data( + data={ + "error": "No elements found with the specified query. Are you sure the query is correct?", + "container": "query-quantities-section-" + str(operator_data["costItemId"]), + }, + data_key="message", + event="message", + ) + return + products_with_quantity = [r for r in results if has_property(r, prop_name)] + if products_with_quantity: + tool.Ifc.run( + "cost.assign_cost_item_quantity", + cost_item=cost_item, + products=results, + prop_name=prop_name, + ) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.send_webui_data( + data={ + "success": f"Successfully assigned quantity from {len(products_with_quantity)} products.", + "container": "query-quantities-section-" + str(operator_data["costItemId"]), + }, + data_key="message", + event="message", + ) + else: + cls.send_webui_data( + data={ + "error": f"Found {len(results)} products, but none with the specified property '{prop_name}'.", + "container": "query-quantities-section-" + str(operator_data["costItemId"]), + }, + data_key="message", + event="message", + ) + if operator_data["type"] == "assignCostValues": + cost_rate = ifc_file.by_id(operator_data["costRateId"]) + cost_schedule = tool.Cost.get_cost_schedule(ifc_file.by_id(operator_data["costItemIds"][0])) + for cost_item_id in operator_data["costItemIds"]: + cost_item = ifc_file.by_id(cost_item_id) + tool.Ifc.run("cost.assign_cost_value", cost_item=cost_item, cost_rate=cost_rate) + cls.load_cost_schedule_web_ui(cost_schedule) + + @classmethod + def load_cost_item_quantities_ui(cls, cost_item: ifcopenshell.entity_instance) -> None: + if not cost_item: + return + selected_products = list(tool.Spatial.get_selected_products()) + selected_products_data = cls.selection_data(cost_item, selected_products) + + assigned_products = tool.Cost.get_cost_item_products(cost_item, is_deep=False) + assigned_products_data = cls.selection_data(cost_item, assigned_products) + + names = ifcopenshell.util.cost.get_product_quantity_names(selected_products) + cls.send_webui_data( + data={ + "selected_products": selected_products_data, + "assigned_products": assigned_products_data, + "product_quantity_names": names, + "cost_item_id": cost_item.id(), + "cost_quantities": tool.Cost.get_cost_quantities(cost_item), + }, + data_key="quantities", + event="quantities", + ) + + @classmethod + def selection_data(cls, cost_item: ifcopenshell.entity_instance, elements: list) -> list: + if not elements: + return [] + if not cost_item: + return [ + { + "info": { + "id": element.id(), + "name": element.Name, + "class": element.is_a(), + "type": get_type(element).Name if get_type(element) else None, + }, + "qtos": get_psets(element, qtos_only=True), + } + for element in elements or [] + ] + data = [] + for element in elements or []: + psets = get_psets(element, qtos_only=True) + element_type = get_type(element) + quantities, unit = tool.Cost.get_assigned_quantities(cost_item, element) + data.append( + { + "info": { + "id": element.id(), + "name": element.Name, + "class": element.is_a(), + "type": element_type.Name if element_type else None, + }, + "qtos": psets, + "assigned_quantities": [ + { + "cost_item_id": cost_item.id(), + "product_id": element.id(), + "name": q.Name, + "value": q[3], + "type": q.Unit, + } + for q in quantities or [] + ], + "unit": unit, + } + ) + return data + + @classmethod + def load_cost_schedule_web_ui(cls, cost_schedule: ifcopenshell.entity_instance) -> None: + ifc2json = ifc5D2json() + ifc2json.file_path = tool.Ifc.get() + ifc2json.cost_schedule = cost_schedule + schedule_data = ifc2json.convert_ifc_to_json() + cls.send_webui_data( + data={ + "currency": tool.Cost.currency(), + "schedule_data": schedule_data, + }, + data_key="cost_items", + event="cost_items", + ) + @classmethod def handle_gantt_operator(cls, operator_data: dict) -> None: """ @@ -337,34 +695,36 @@ class Web(bonsai.core.tool.Web): operator_data (dict): A dictionary containing the operator data. """ ifc_file = tool.Ifc.get() + if operator_data["type"] == "getWorkSchedules": + work_schedules = ifc_file.by_type("IfcWorkSchedule") + work_schedules_json = [ws.get_info(recursive=True) for ws in work_schedules] + cls.send_webui_data(data=work_schedules_json, data_key="work_schedule_info", event="work_schedule_info") + if operator_data["type"] == "loadWorkSchedule": + work_schedule = ifc_file.by_id(operator_data["workScheduleId"]) + print("Generating Gantt chart") + bonsai.core.sequence.enable_editing_work_schedule_tasks(tool.Sequence, work_schedule=work_schedule) + bonsai.core.sequence.generate_gantt_chart(tool.Sequence, work_schedule=work_schedule) if operator_data["type"] == "editTask": task_id = int(operator_data["taskId"]) task = ifc_file.by_id(task_id) task_time = task.TaskTime column = operator_data["column"] new_value = operator_data["value"] + if task_time is None: + ifcopenshell.api.sequence.add_task_time(ifc_file, task) + task_time = task.TaskTime + ifcopenshell.api.sequence.edit_task_time( + ifc_file, task_time=task_time, attributes={IFC_TASK_ATTRIBUTE_MAP[column]: str(new_value)} + ) - try: - ifcopenshell.api.sequence.edit_task( - ifc_file, task, attributes={IFC_TASK_ATTRIBUTE_MAP[column]: str(new_value)} - ) - except AttributeError: - if task_time is None: - ifcopenshell.api.sequence.add_task_time(ifc_file, task) - task_time = task.TaskTime - ifcopenshell.api.sequence.edit_task_time( - ifc_file, task_time=task_time, attributes={IFC_TASK_ATTRIBUTE_MAP[column]: str(new_value)} - ) - - bpy.ops.bim.load_task_properties() - - # after updating, send new gantt data to handle the case where - # changing a task cascades and changes other tasks. as this wouldn't - # be reflected in the web ui - work_schedule = ifc_file.by_id(operator_data["workScheduleId"]) - task_json = tool.Sequence.create_tasks_json(work_schedule) - gantt_data = {"tasks": task_json, "work_schedule": work_schedule.get_info(recursive=True)} - cls.send_webui_data(data=gantt_data, data_key="gantt_data", event="gantt_data") + bpy.ops.bim.load_task_properties() + # after updating, send new gantt data to handle the case where + # changing a task cascades and changes other tasks. as this wouldn't + # be reflected in the web ui + work_schedule = ifc_file.by_id(operator_data["workScheduleId"]) + task_json = tool.Sequence.create_tasks_json(work_schedule) + gantt_data = {"tasks": task_json, "work_schedule": work_schedule.get_info(recursive=True)} + cls.send_webui_data(data=gantt_data, data_key="gantt_data", event="gantt_data") @classmethod def handle_drawings_operator(cls, operator_data: dict) -> None: @@ -387,6 +747,7 @@ class Web(bonsai.core.tool.Web): continue reference_name = os.path.basename(reference.Location) reference_path = os.path.join(ifc_file_dir, reference.Location) + reference_path = os.path.normpath(reference_path) sheets_data.append({"name": reference_name, "path": reference_path}) drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"] @@ -394,6 +755,7 @@ class Web(bonsai.core.tool.Web): document = tool.Drawing.get_drawing_document(drawing) reference_name = os.path.basename(document.Location) reference_path = os.path.join(ifc_file_dir, document.Location) + reference_path = os.path.normpath(reference_path) drawings_data.append({"name": reference_name, "path": reference_path}) cls.send_webui_data( diff --git a/src/bonsai/docs/guides/authoring/understanding_ifc.rst b/src/bonsai/docs/guides/authoring/understanding_ifc.rst index 9037bd68c0..f5cef70952 100644 --- a/src/bonsai/docs/guides/authoring/understanding_ifc.rst +++ b/src/bonsai/docs/guides/authoring/understanding_ifc.rst @@ -49,22 +49,22 @@ This section provides an overview of key IFC concepts and how they're implemente .. container:: card - :doc:`ifc_concepts/spatial_objects` + :doc:`spatial_objects` Basic spatial objects. .. container:: card - :doc:`ifc_concepts/classification_and_types` + :doc:`classification_and_types` IFC classification hierarchy and the concept of types and occurrences. .. container:: card - :doc:`ifc_concepts/geometry_and_representations` + :doc:`geometry_and_representations` Understanding IFC geometry, representations, and parametric materials. .. container:: card - :doc:`ifc_concepts/working_with_representations` + :doc:`working_with_representations` Representations in Bonsai. .. container:: global-index-toc @@ -74,7 +74,7 @@ This section provides an overview of key IFC concepts and how they're implemente :caption: Understanding IFC :maxdepth: 2 - ifc_concepts/spatial_objects - ifc_concepts/classification_and_types - ifc_concepts/geometry_and_representations - ifc_concepts/working_with_representations + spatial_objects + classification_and_types + geometry_and_representations + working_with_representations diff --git a/src/bonsai/docs/guides/development/code_style.rst b/src/bonsai/docs/guides/development/code_style.rst new file mode 100644 index 0000000000..8857d6baab --- /dev/null +++ b/src/bonsai/docs/guides/development/code_style.rst @@ -0,0 +1,21 @@ +Code style +============ + + +Black code formatter +------------------------------- +For Python code formatting, we use `Black code formatter `__, +black settings are stored in the repository's pyproject.toml. + +We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository. + +``black`` can be installed using ``pip install black`` and files can be formatted with the following example command: + +.. code-block:: bash + + # Format the entire repository. + # Should be used in 99% cases as the entire repository is already formatted using black. + black . + # Format only some specific file. + black src/bonsai/bonsai/bim/module/qto/operator.py + diff --git a/src/bonsai/docs/guides/development/getting_started.rst b/src/bonsai/docs/guides/development/getting_started.rst new file mode 100644 index 0000000000..7aa6495c40 --- /dev/null +++ b/src/bonsai/docs/guides/development/getting_started.rst @@ -0,0 +1,28 @@ +Getting Started +=============== + +Bonsai is an open-source project, and its development is driven by the contributions of a dedicated community of developers, +architects, engineers, and enthusiasts. If you're interested in contributing to the project, whether by submitting bug reports, +suggesting new features, or contributing code, your involvement is highly encouraged and appreciated. + +This part of the documentation covers various aspects of the Bonsai development process, including: + +- :doc:`Writing User Documentation ` +- :doc:`Translations and Internationalisation ` +- Contributing Code + - :doc:`Installation and Setting up a Development Environment ` + - Understanding the Project Structure and Codebase + - :doc:`Hello, World! ` + - :doc:`Undo System ` + - :doc:`Code Style Guidelines and Best Practices ` + +.. + - :doc:`Submitting Pull Requests and Contributing Code ` + +- :doc:`Testing and Quality Assurance ` +- :doc:`System Support and Multiplatform Compatibility ` +- :doc:`User Experience and User Interface Guidelines ` + +The Bonsai Developer Documentation is a living resource maintained by the core development team and the open-source community. It serves as a central hub for developers who want to get involved in the project, ensuring a consistent and efficient development process. + +By contributing to Bonsai, you'll not only be helping to improve and expand the capabilities of this powerful open-source BIM authoring platform but also be part of a vibrant community driving innovation in the AEC industry. diff --git a/src/bonsai/docs/guides/development/images/unstable-auto-update.png b/src/bonsai/docs/guides/development/images/unstable-auto-update.png new file mode 100644 index 0000000000..7262145b57 Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-auto-update.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-drag-drop.png b/src/bonsai/docs/guides/development/images/unstable-drag-drop.png new file mode 100644 index 0000000000..efe9ca4d56 Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-drag-drop.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-icon.png b/src/bonsai/docs/guides/development/images/unstable-icon.png new file mode 100644 index 0000000000..b92fc7d463 Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-icon.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-repo.png b/src/bonsai/docs/guides/development/images/unstable-repo.png new file mode 100644 index 0000000000..d62e867dcd Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-repo.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-restart.png b/src/bonsai/docs/guides/development/images/unstable-restart.png new file mode 100644 index 0000000000..cb4f13a46b Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-restart.png differ diff --git a/src/bonsai/docs/guides/development/index.rst b/src/bonsai/docs/guides/development/index.rst index e25659178b..d0a0e46bf5 100644 --- a/src/bonsai/docs/guides/development/index.rst +++ b/src/bonsai/docs/guides/development/index.rst @@ -9,6 +9,7 @@ This chapter covers how you can help contribute to Bonsai. :hidden: :maxdepth: 2 + installation hello_world running_tests translations diff --git a/src/bonsai/docs/guides/development/installation.rst b/src/bonsai/docs/guides/development/installation.rst index 8cddf8c1e3..f8d778d77c 100644 --- a/src/bonsai/docs/guides/development/installation.rst +++ b/src/bonsai/docs/guides/development/installation.rst @@ -3,20 +3,16 @@ Installation There are different methods of installation, depending on your situation. -1. **Unstable installation** is recommended for power users helping with testing. -2. **Bundling for Blender** is recommended for distributing the add-on. -3. **Live development environment** is recommended for developers who are actively coding. -4. **Packaged installation** is recommended for those who use a package manager. +1. :ref:`guides/development/installation:Unstable installation` is recommended + for power users helping with testing. +2. :ref:`guides/development/installation:Bundling for blender` is recommended for distributing the add-on. +3. :ref:`guides/development/installation:Live development environment` is + recommended for developers who are actively coding. +4. :ref:`guides/development/installation:Packaged installation` is recommended + for those who use a package manager. -Unstable installation ---------------------- - -**Unstable installation** is almost the same as **Stable installation**, except -that they are typically updated every day. Simply download a daily build from -the `GitHub releases page -`__, -then follow the usual :doc:`installation -instructions`. +System requirements +------------------- Bonsai officially supports all major 64-bit platforms, as well as the Python version shipped by the Blender Foundation for the most recent three major @@ -41,6 +37,76 @@ Other system specifications match the `Blender Requirements Sometimes, a build may be delayed, or contain broken code. We try to avoid this, but it happens. +Unstable installation +--------------------- + +**Unstable installation** is almost the same as **Stable installation**, except +that they are typically updated every day. To install the **Unstable** version: + +1. Open up Blender, and click on ``Edit > Preferences``. + + .. image:: /quickstart/images/install-bonsai-1.png + +2. Select the **Get Extensions** tab, and press **Allow Online Access**. + + .. image:: /quickstart/images/install-bonsai-2.png + +3. Go to the `Bonsai Unstable Repository + `__, and drag and drop + from the appropriate link in the ``ID`` column of the table into Blender + depending on your operating system. + + .. image:: images/unstable-drag-drop.png + +4. Enable **Check for Updates on Startup** to get updates for daily Bonsai + builds automatically. + + .. image:: images/unstable-auto-update.png + +.. tip:: + + Instead of drag and drop, you can manually create the repository: + + Open :menuselection:`Topbar --> Edit --> Preferences --> Get Extensions + --> Repositories (Top Right) --> "+" Icon --> Add Remote Remository`. + You'll see a window similar to the one above. + + Use as URL: + ``https://raw.githubusercontent.com/IfcOpenShell/bonsai_unstable_repo/main/index.json`` + and enable **Check for Updates on Startup** if you want them. + +5. Search for **Bonsai** in the top left search bar, then press the **Install** + button. + + .. image:: /quickstart/images/install-bonsai-3.png + +.. warning:: + + Make sure the extension you install has ``raw.githubusercontent.com`` as + it's "Repository" (not ``extensions.blender.org``). + + .. image:: images/unstable-repo.png + +6. Whenever a new update is available, you'll see it in the bottom right + :menuselection:`Status Bar` + + .. image:: images/unstable-icon.png + +7. To update, click on the update button in :menuselection:`Topbar --> Edit --> + Preferences --> Get Extensions`. + + .. image:: /guides/images/update.png + +8. After an update, be sure to restart. + + .. image:: images/unstable-restart.png + +If you wish to install an **Unstable** version offline, you can download a +daily build from the `GitHub releases page +`__, +then go to :menuselection:`Topbar --> Edit --> Preferences --> Get Extensions +--> "V" Icon (top right) --> Install from Disk`. + Bundling for Blender -------------------- @@ -72,12 +138,13 @@ Live development environment ---------------------------- One option for developers who want to actively develop from source is to follow -the instructions from :ref:`devs/installation:Bundling for Blender`. However, +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. -A more rapid approach is to follow the :ref:`devs/installation:Unstable -installation` method, as this provides all 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 @@ -86,7 +153,7 @@ our Blender installation (you will need to restart Blender to see changes). For Linux or Mac: -.. literalinclude:: ../../scripts/installation/dev_environment.sh +.. literalinclude:: ../../../scripts/installation/dev_environment.sh :language: bash :caption: dev_environment.sh @@ -94,7 +161,7 @@ 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. -.. literalinclude:: ../../scripts/installation/dev_environment.bat +.. literalinclude:: ../../../scripts/installation/dev_environment.bat :language: bat :caption: dev_environment.bat diff --git a/src/bonsai/docs/guides/development/ux_guidelines.rst b/src/bonsai/docs/guides/development/ux_guidelines.rst new file mode 100644 index 0000000000..96e526baf1 --- /dev/null +++ b/src/bonsai/docs/guides/development/ux_guidelines.rst @@ -0,0 +1,178 @@ +User Experience and User Interface guidelines +============================================= + +This is intended as a reference document for developers working on the Bonsai. + +Operators +^^^^^^^^^ + +BIM will by definition contain a (big) number of submodules or subschemas. Granting access to the features of these submodules to the users needs to be considered with several things in minds : + +1. Ease of use : How do I achieve my goal with the minimal number of clicks ? How many different menu items do I have to go through to get to my goal ? How long do I have to scroll a menu to get to the specific thing I have in mind ? + +2. Clarity : How long do I have to visually parse the interface to find the item or button I'm looking for ? Are the buttons correctly labeled and are the icons descriptive enough ? If something is forbidden, can I easily know why ? Can I know what a button does before clicking on it ? + +3. Completeness : I need to have access to all the items that are related to the topic I'm interested in. If it is tied to another submodule, I need to have access to it. + +* **Blender** + + These considerations must be adapted to the Python API, to the general paradigm we have chosen to access, modify or create IFC data, and to the general philosophy of Blender. There are many ways and places where an add-on creator can choose to present information to the user. Bonsai currently works that way : + + - Information about the current context is displayed inside the properties editor. We leverage the fact that it is already built around displaying different types of information whether we are in the Scene, Material, Mesh, Object, Curve, Texture, etc. subpanels. It fits really well with the way things are already organized in the IFC classification. + + - Tools to create, modify or delete items are in the dedicated Toolbar (The left one) in the 3D viewport + + - Some Misc tools and information are displayed in the N panel of the 3D viewport . They're shortcuts to things that are already present elsewhere in the UI, but are handy to have when modifying objects or geometry in the Viewport. + + - Adding specific Import / Export IFC operators (arguably a bit misleading since Bonsai does more than import / export in these operations) + +* **Interface panels** + + - The good : Every submodule is visually separated from each other. The user can customize its interface to only show what's relevant to them at any one point by folding in or out panels. Development / Debugging is facilitated because each submodule's interface is separated from the others. + + - The bad : Having many interface panels clutters the interface, leading to an overwhelming experience for newcomers especially. + + - Proposal : The addon should in the long run provide the user with dynamic UI depending on what's their usecase. Preferably accessed in the Addon Preferences interface, with pre-made options that dynamically enable or disable specific parts of the UI. It should also provide the option to selectively add or remove specific parts of the UI on top of that. Additionally, when it does not make sense to display a specific panel, it should be hidden from the UI. (eg if an object has no IFC Class, it can't be spatially contained, so the IFC Spatial Container panel should be hidden until the user sets the IFC class) + In the long term it could also be interesting to think about some kind of tutorial mode where more information is conveyed through the interface for new users. + +* **Item Lists** + + An item list should provide right off the bat a few features : + + - Add a new item. + + - Modify an item, whether by adding a button to each item in the list, or adding a button on the list header to modify the selected item. + + - Delete an item, whether by adding a button to each item in the list, or adding a button on the list header to delete the selected item. + + - Modify an item's name by double-clicking on the field if applicable + + - The currently selected item should be highlighted + + - There should be a search field to filter through specific items in the list, with buttons to invert selection, sort alphabetically and revert display. (Features that are provided by the `UIList` class) + +* **Operators** + + - Some buttons should be dynamically disabled (grayed out and impossible to click on) when the context to execute them does not make sense. It is important to note there should not be any heavy computation to determine this since this is executed several times per second for each button where it is implemented. It should only test for simple things and not rely on retrieving information from other modules too much. eg : `Is an item selected ?` `Is the z position of the selected object > 0 ?` or `Is the selected object an IfcOpeningElement ?` and **not** `Is there an IfcOpeningElement in the current file ?` or `How many different classes of IfcBeam are implemented in the file ?` + In Blender this is done in two ways : + + - Directly in the operator `poll` method which is executed when the button is displayed in the interface. The drawback is that some operators rely on custom attributes which are provided only when the user clicks on the button, and cannot be test in the `poll` method. Note in V3.0 we can implement custom messages when the `poll` method fails, depending on the step where the context was not right. + + - In the UI drawing code, where specific layout parts can be disabled. + + - Some buttons or entire interface layout rows or columns may be disabled or hidden at once. It may be desirable when it relies on specific things or combinations of things not being met in the project. In order to avoid UI flicker or the user wondering where the button went, dynamically hiding UI elements should have a minimal interference with the rest of the UI by : + + (1) Hiding the last row(s) of a panel + + (2) Dynamically resizing the rest of the UI when it's not possible to (1) + + The user should be advised why they are prevented to do things via either : + + - A custom message using Blender's report system + + - A label in the UI replacing the missing UI elements + + - A helpful tooltip on an operator + + Generally the context itself should be used to the best of its ability to convey why it's possible to do some things or not. The separation of concerns in multiple different dynamically hidden-or-shown sub-panels should help limit confusion. + + - The user should be forbidden to click on buttons when its execution returns early and does nothing. If a button is clickable and the user clicks on it, there should always be some kind of feedback. + +Icons +^^^^^ + +* .. image:: https://user-images.githubusercontent.com/25156105/137270452-bf6c03ea-2d3e-4ae3-8121-6799e8beb335.png + +**Icon name** : `"CANCEL"` + +**Use** : To disable the edition state of a particular item. + +**Expected result** : A part of the interface showing the item attributes is either shrunk down or hidden. The button is usually replaced by a button to delete this item. + +* .. image:: https://user-images.githubusercontent.com/25156105/137271575-dcae9e56-3c64-4d8b-89b0-6cf103b999f2.png + +**Icon name** : `"X"` + +**Use** : To delete a particular item, for instance an element of a list. + +**Expected result** : The interface element referencing the deleted item is hidden from the interface. If the item was in a list, and the deleted element was selected, the next element becomes selected, or if there is no next element, no element is selected. + +* .. image:: https://user-images.githubusercontent.com/25156105/137271601-e79012e5-fc61-402e-830c-b34b4ca757a0.png + +**Icon name** : `"GREASEPENCIL"` + +**Use** : To Enable the edition of a particular item. + +**Behaviour** : Usually only one element of a particular context can be edited at a time. + +**Expected result** : The interface should either expand or spawn new elements to display the chosen item's editable attributes. If the editem item is part of a list and its interface elements contained an operator to delete it, it is swapped for an operator to disable the edition of that item. + +**Proposal** : Currently all the edition buttons are hidden from the interface when editing a particular item. This results on buttons jumping to the right slightly. My proposal is to instead show but disable the other buttons to prevent interface flicker. + +**Current** : https://user-images.githubusercontent.com/25156105/137273603-f9ac39e5-7616-4fb9-89e2-4dae453e650a.gif + +**Proposal** : https://user-images.githubusercontent.com/25156105/137273329-532d6d59-e39d-483e-980b-b8405062eaa8.gif + +.. image:: https://user-images.githubusercontent.com/25156105/137273981-26fa664a-a29e-402e-9d7e-67b8b71c2d5d.png + +* **Icon name** : `"ADD"` + +**Use** : To add an item to a particular context, either initializing it (eg. adding a coordinate system) or adding an element to a list. + +**Expected result** : The interface should either expand to display the new item or a new line should be shown if the item is added in a list. + +**Proposal** : In some instances adding an element in a list automatically enables the edition of this item's attributes. (eg IFC Groups) but in other instances it does not (eg IFC strucural load cases). It is arguable but I do not think the user should expect a new item to be in editable state right off the bat when they add it to a list. + +* .. image:: https://user-images.githubusercontent.com/25156105/137274869-0d0b876a-545c-4a58-a386-90f85764b7cc.png + +**Icon name** : `"RESTRICT_SELECT_OFF"` + +**Use** : To select all the instances related to a particular item. + +**Expected result** : All the objects relating to a particular item should be selected in the 3D viewport and/or in the outline. + +**Proposal** : Whether or not previously selected objects should be deselected beforehand is up for debate I think, because both behaviours can be desirable. It also raises the question of which object should be set to active. (Random ? First one ? Biggest one ?...) + +* .. image:: https://user-images.githubusercontent.com/25156105/137275582-98c1dfcc-fc96-41d6-80dd-fdb82796e66e.png + +**Icon name** : `"IMPORT"` + +**Use** : to import a particular file from the user's computer's directory to the current context + +**Expected result** : A new file browser window should open, with sufficient information provided to the user to know which file type should be loaded in (eg adding a file extension, adding information in the filebrowser UI). When the file is loaded, the interface should expand to show the attributes or information or new context-sensitive operations provided by the imported file. + +**Proposal** : Currently there are some instances where I don't know if the elements are supposed to require an external file, eg + +.. image:: https://user-images.githubusercontent.com/25156105/137275887-e0f0b694-842b-422f-bc09-cdcefef64107.png + +* .. image:: https://user-images.githubusercontent.com/25156105/137276333-4d083f6a-36b9-494f-b0de-ec1d321cbd3d.png + +**Icon name** : `"EXPORT"` + +**Use** : To export a particular file to the user's computer's directory + +**Expected result** : A new file browser window should open, with sufficient information provided to the user to know which file type is going to be saved (eg adding a file extension, adding information in the filebrowser UI). + +**Proposal** : When there is no actual file to be saved in the user's directory or the filepath is already set in another place in the UI : + +.. image:: https://user-images.githubusercontent.com/25156105/137276840-3ceda0cd-87bd-45f5-8488-bfce86bf3d84.png + +It may be better to use a save icon of some sort instead, eg + +.. image:: https://user-images.githubusercontent.com/25156105/137276758-5d60f0ca-5a93-4af3-9c4f-30c5794e0657.png + +* .. image:: https://user-images.githubusercontent.com/25156105/137277047-bd45df5b-3b47-4b2c-aa29-a340b0710345.png + +**Icon name** : `"FILEBROWSER"` + +**Use** : To Provide the current context with the path to a particular file or folder + +**Expected result** : A new file browser window should open, with sufficient information provided to the user to know which file type is required (eg adding a file extension, adding information in the filebrowser UI). Upon file or folder selection, this should populate a field in the current interface context with the path to the selected file or folder, or at least let the user know that the required information is now correctly filled in. + +* .. image:: https://user-images.githubusercontent.com/25156105/137277374-fa7ff310-0b41-49a9-98bd-7c4686da05fd.png + +**Icon name** : `"EYEDROPPER"` + +**Use** : To select a particular object in the interface to fill the field it is attached to + +**Expected result** : The cursor should change to an eyedropper until the user clicks on the required item. The user should be made aware after selection if it was successful (associated field being filled) or unsuccessful (message ? or field staying empty) diff --git a/src/bonsai/docs/guides/troubleshooting.rst b/src/bonsai/docs/guides/troubleshooting.rst index f8f0aa9290..882ccecb4f 100644 --- a/src/bonsai/docs/guides/troubleshooting.rst +++ b/src/bonsai/docs/guides/troubleshooting.rst @@ -30,13 +30,13 @@ Updating We always recommend to use the latest version. -Open up Blender, click on ``Edit > Preferences``, and select the **Get -Extensions** tab. If an update is available, you will see a button next to the +Open up Blender, click on :menuselection:`Topbar --> Edit --> Preferences --> +Get Extensions`. If an update is available, you will see a button next to the **Bonsai** add-on. Updates are typically available every 2 months. If you need something more -frequent, check out :ref:`devs/installation:unstable installation` which is -updated every day. +frequent, check out :ref:`guides/development/installation:Unstable +installation` which is updated every day. .. image:: images/update.png @@ -58,10 +58,10 @@ can `report a bug `_ or with factory settings. To quickly test in a clean environment, first :ref:`find your Blender - configuration folder`. Rename the folder from ``X.XX`` to something else like ``X.XX_backup``, then restart Blender and try follow the :doc:`installation - instructions` again. + instructions` again. If this fixes your issue, consider disabling other add-ons one by one until you find a conflict as a next step to isolating the issue. @@ -94,11 +94,33 @@ Blender geometry that represents what the model might've looked at at some point. At worst, you might be looking at a completely wrong model. If you continue to open and save ``.blend`` files, you will run the risk of -editing something that doesn't actually exist in your IFC model. This will +editing something that doesn't actually exist in your IFC model (e.g you can +meet an error similar to "RuntimeError: Instance #1234 not found"). This will create unpredictable, and sometimes unrecoverable errors. To avoid this issue, only open and save IFCs. +Incompatible Blender features +----------------------------- + +Blender offers features such as animation, sculpting, modifiers, and more that +are not available in IFC. As :ref:`Bonsai uses IFC as its native data +store`, using these +incompatible features may result in data loss. + +1. **Manual geometry editing**. Manually changing the mode to Edit or Object + mode in the :menuselection:`3D Viewport` top left dropdown menu. This + changes the Blender mesh editing mode, but any changes may be incompatible + with the IFC geometry. This is because IFC geometry may not be a mesh, or + have parametric constraints. Any changes made in this manner may have + unpredictable effects and changes may be discarded. Instead, always use the + :kbd:`Tab` key to toggle object editing, or use the IFC Object / Edit mode + toggle in the top right of the :menuselection:`3D Viewport`. +2. **Object scaling**. Bonsai does not support scaled objects in IFC. Any + 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. + Where is the add-on installed? ------------------------------ diff --git a/src/bonsai/docs/index.rst b/src/bonsai/docs/index.rst index ea2d1c049f..1775f95e35 100644 --- a/src/bonsai/docs/index.rst +++ b/src/bonsai/docs/index.rst @@ -71,6 +71,6 @@ and data-rich OpenBIM with Blender :) Need more help? Join the `live chat `__ or `community forums `__. Something strange happening? Please `report a bug `__. Want to -improve the software? Help with :doc:`coding ` or `chip +improve the software? Help with :doc:`coding ` or `chip in financially `__. Every little bit adds up! diff --git a/src/bonsai/docs/quickstart/create_model.rst b/src/bonsai/docs/quickstart/create_model.rst index 4c07e634eb..581436ce9d 100644 --- a/src/bonsai/docs/quickstart/create_model.rst +++ b/src/bonsai/docs/quickstart/create_model.rst @@ -23,7 +23,7 @@ Metric (m) Project**. This will begin a blank IFC4 project. In the :menuselection:`Properties --> Project Overview --> Spatial Decomposition` panel, you will see a hierarchy of spaces that has been -automatically created for you. This hierarchy is known is the **Spatial Tree**. +automatically created for you. This hierarchy is known as the **Spatial Tree**. .. image:: images/default-spatial-tree.png diff --git a/src/bonsai/docs/quickstart/explore_model.rst b/src/bonsai/docs/quickstart/explore_model.rst index 26851df24b..3ccc73830c 100644 --- a/src/bonsai/docs/quickstart/explore_model.rst +++ b/src/bonsai/docs/quickstart/explore_model.rst @@ -121,49 +121,34 @@ pressing the button on the top right of the **Viewport** panel. The hotkey is Overview of all objects ----------------------- -The **Outliner** panel on the top right shows a hierarchy of all the currently -loaded physical **Objects** in your IFC project. These **Objects** correlate to -what you can see in the **Viewport** panel. +All physical **Elements** are organised in a hierarchy of **Spatial +Containers**. By default, this hierarchy represents a breakdown of spaces, from +large spaces such as a site and a building, down to smaller spaces like +building storeys and room spaces. The hierarchy will always begin with an +**IfcProject**. -Every **Object** in the **Outliner** represents an IFC **Element**. These -**Objects** have a name with the pattern ``Class/Name``. The class prefix -represents the type of object, and the name is the name of the object. Examples -of classes you will see are ``IfcBuilding``, or ``IfcWall``. This naming -convention makes it easy to quickly spot types of objects. +The :menuselection:`Properties --> Project Overview --> Spatial Decomposition` +panel shows this hierarchy. Below, it shows a list of all **Elements** +contained inside the actively selected **Spatial Container**. -.. image:: images/outliner.png +.. image:: images/spatial-tree.png -Objects are organised in a hierarchy. By default, this hierarchy represents a -breakdown of spaces, from large spaces such as a site and a building, down to -smaller spaces like building storeys and room spaces. The hierarchy will always -begin with an **IfcProject** object. You can click on the triangle to toggle the -hierarchy. +With a **Spatial Container** selected, use the **Isolate** button or **Hide / +Show Icons** to quickly focus or control visibility. Use the search filters at +the bottom of the **Container** or **Element** lists to quickly find objects, +and use the **Select Icon** to select them. + +.. image:: images/spatial-tree-features.png + +**Elements** are grouped into IFC **Classes**, such as Wall, Slab, or Door. +Within that, **Elements** are grouped into **Construction Types**. You'll see a +count of how many objects of that type exist. In the example above, there is +only one stair contained in the building storey. .. tip:: - In large projects with deep hierarchies, you can ``Shift-LMB`` click the - triangle to recursively toggle the hierarchy. You can also click and drag the - ``MMB`` to pan left and right. - -When there are lots of objects, you can type a name in the filter box to quickly -identify objects by name or type. - -.. image:: images/outliner-filter.png - -Clicking on an object in the **Outliner** panel also selects the corresponding -object in the **Viewport** panel. A good strategy to find objects is to then use -``View > Frame Selected`` to zoom to it in the **Viewport**. - -The **Outliner** panel is also great for isolating portions of your project. You -can include and exclude portions by clicking on the **Tick Icon** next to -collections of objects in the hierarchy. - -Let's isolate a single building storey. Start by disabling the **Tick Icon** -next to the **IfcProject** collection. This will hide everything in the project. -Then navigate through the hierarchy and enable the **Tick Icon** next to an -**IfcBuildingStory**. - -.. image:: images/outliner-isolate.png + Hold :kbd:`Alt` when clicking on triangles in the hierarchy to show / hide + children recursively. Viewing element classes ----------------------- @@ -185,16 +170,15 @@ properties and relationships it is allowed to have. For example, a Wall worry about memorising all the available **Classes**, you'll get a feel for them as you explore more. -To view an object's class, click on an object in the **Viewport** or **Outliner** -panel, then switch to the **Object Information** tab in the **Properties** panel. -You can see the **Class** name in the **Object Metadata** subpanel. +To view an object's class, click on an object in the :menuselection:`3D +Viewport`, then go to :menuselection:`Properties --> Object Information --> +Object Metadata` to see the **Class** name. .. image:: images/element-class.png -In this case, the **Class** of our roof is an **IfcSlab**. You'll notice this is -the same **Class** name used as a prefix for the object name in the **Outliner** -panel. You can also see the name of the actively selected object in the top left -of the **Viewport** panel. +In this case, the **Class** of our roof is an **IfcSlab**. You can also see the +name of the actively selected object in the top left of the :menuselection:`3D +Viewport`. .. warning:: @@ -219,24 +203,26 @@ not see it all the time. classes and predefined types you should see. Press the **Select Icon** to select all objects that are of the same -**IfcSlab** **Class**. Then, you can isolate these elements by going to ``Object -> Show/Hide > Hide Unselected`` (hotkey ``Shift-H``). To show all elements again, you can use -``Object > Show/Hide > Show Hidden Objects`` (hotkey ``Alt-H``). If you want to -hide elements instead, you can use ``Object > Show/Hidden > Hide Selected`` -(hotkey ``H``). +**IfcSlab** **Class**. Then, you can isolate these elements by going to +:menuselection:`3D Viewport --> Object --> Show/Hide --> Hide Unselected` +(:kbd:`Shift-H`). To show all elements again, you can use :menuselection:`3D +Viewport --> Object --> Show/Hide --> Show Hidden Objects` (:kbd:`Alt-H`). If +you want to hide elements instead, you can use :menuselection:`3D Viewport --> +Object --> Show/Hidden --> Hide Selected` (:kbd:`H`). .. image:: images/element-class-select.png .. note:: Remember that Blender's hotkeys are context sensitive. Make sure your mouse - is hovering over the **Viewport** panel when you press a hotkey or no cake - for you. + is hovering over the :menuselection:`Viewport` panel when you press a hotkey + or no cake for you. You can also see statistics about the number of selected objects. If you right -click on the bottom right status bar and enable **Scene Statistics** you will -see information like **Objects 4/4**, which means that 4 objects are selected -out of 4 available objects. This is a great way of counting objects like toilets. +click on the bottom right :menuselection:`Status Bar` and enable **Scene +Statistics** you will see information like **Objects 4/4**, which means that 4 +objects are selected out of 4 available objects. This is a great way of +counting objects like toilets. .. image:: images/scene-statistics.png @@ -244,7 +230,7 @@ Viewing attributes and properties --------------------------------- You can view the **Attributes**, **Properties**, and **Quantities** of the -selected object in the **Object Properties** tab. +selected object in the :menuselection:`Properties --> Object Information` tab. Let's focus on **Attributes** first. Scroll down to the **Attributes** subpanel. **Attributes** are a limited set of fundamental data (usually less @@ -279,7 +265,8 @@ have different **Properties** depending on what information they want to store. Each **Property** has a name and a value, and are grouped into **Property Sets**. Each **Property Set** also has a name. -You can find **Properties** in the **Object Property Sets** subpanel. +You can find **Properties** in the :menuselection:`Properties --> Object +Information --> Property Sets` panel. .. image:: images/psets.png @@ -303,7 +290,8 @@ value, and are grouped into **Quantity Sets**. Similarly, there are also common quantities defined as part of the international standard, denoted by the prefix ``Qto_``. This prefix is short for "Quantity Take-Off". -You can find **Quantities** in the **Object Quantity Sets** subpanel. +You can find **Quantities** in the :menuselection:`Properties --> Object +Information --> Quantity Sets` panel. .. image:: images/qtos.png @@ -313,16 +301,17 @@ Finding the location of objects Every object in the built environment has a location in the world. For example, a chair will be located in a space, and a wall is typically located in a building storey. You've already seen this hierarchy of spaces in the -**Outliner** panel, where an IFC project is broken down into site, building, -storeys, and spaces. +:menuselection:`Properties --> Project Overview --> Spatial Decomposition` +panel, where an IFC project is broken down into site, building, storeys, and +spaces. Sometimes, objects may have multiple relevant locations, such as a multi-storey column which can be related to multiple building storeys. Even in these cases, IFC enforces one location to be its primary location, known as its **Spatial Container**. -If you click on any object, you can see its location in the **Spatial -Container** subpanel in the **Object Information** tab. +If you click on any object, you can see its location in the +:menuselection:`Properties --> Object Information --> Spatial Container` panel. Press the **Select Icon** to select all objects that are in the same location. @@ -334,20 +323,22 @@ Checking construction types Almost everything in the built environment will have a **Construction Type**. For example, an architect will specify a door type for every door in a project. -You can see a list of **Construction Types** in the **Outliner** panel in the -**Types** collection. For example, if the architect has a wall types schedule -with the wall type names of ``WT01``, ``WT02``, and ``WT03``, you should see -three **IfcWallType** objects with those same names in the **Outliner**. +You can see a list of **Construction Types** in the :menuselection:`Outliner` +in the **Types** collection. For example, if the architect has a wall types +schedule with the wall type names of ``WT01``, ``WT02``, and ``WT03``, you +should see three **IfcWallType** objects with those same names in the +**Outliner**. You can click on these types to see more details about them in the -**Properties** panel. +:menuselection:`Properties` panel. .. image:: images/outliner-types.png -When selecting an object, you can also see its construction type in **Object -Information** under the **Type** subpanel. You can press the **Select Icon** to -select all objects that are of the same **Construction Type**. You can use the -hide and isolate hotkeys to quickly view them in the model. +When selecting an object, you can also see its construction type in +:menuselection:`Properties --> Object Information --> Type`. You can press the +**Select Icon** to select all objects that are of the same **Construction +Type**. You can use the hide and isolate hotkeys to quickly view them in the +model. .. image:: images/properties-types.png @@ -361,10 +352,10 @@ geometry of the pump, so all occurrences of that pump will have the same geometry. You can visually inspect types in isolation to the rest of the model. Types are -hidden by default, so first enable the visibility of the **Types** collection in -the **Outliner** by pressing the **Visibility Icon**. Then, select a type, and -click on ``View > Local View > Toggle Local View`` (hotkey ``/``) in the -**Viewport**. Toggle the view to see the entire model again. +hidden by default, so first enable the visibility of the **Types** collection +in the **Outliner** by pressing the **Visibility Icon**. Then, select a type, +and click on :menuselection:`3D Viewport --> View --> Local View --> Toggle +Local View` (:kbd:`/`). Toggle the view to see the entire model again. .. image:: images/type-local-view.png @@ -383,8 +374,8 @@ resources. For example, a **Material** might be blockwork. Another **Material** might be in-situ concrete. **Materials** are grouped into categories like steel, concrete, brick, block, and so on. -We can see a list of **Materials** used in the project in the **Materials** -subpanel in the **Geometry and Materials** tab. +We can see a list of **Materials** used in the project in the +:menuselection:`Properties --> Geometry and Materials --> Materials` panel. Press the **Select Icon** to select all objects that are of the selected material. @@ -395,18 +386,20 @@ Taking simple measurements -------------------------- The simplest form of measurement is the one that's already taken for you. The -**Viewing attributes and properties** section describes how to view -pre-calculated **Quantities**. +:ref:`quickstart/explore_model:Viewing attributes and properties` section +describes how to view pre-calculated **Quantities**. Sometimes, you may wish to take manual measurements yourself. You can view the overall X, Y, and Z dimensions of the currently selected object in the -**Derived Coordinates** subpanel in the **Geometry and Materials** tab. +:menuselection:`Properties --> Geometry and Materials --> Placement --> Derived +Coordinates` panel. .. image:: images/dimensions.png Another way to manually measure from two points is to use the **Measure** tool. First, press the **Snap Icon** to enable snapping. Then choose snap targets in -the **Snap Menu** in the top middle section of the **Viewport** panel. +the **Snap Menu** in the top middle section of the :menuselection:`3D +Viewport`. .. image:: images/snap-targets.png @@ -418,12 +411,12 @@ the **Snap Menu** in the top middle section of the **Viewport** panel. measurements will automatically snap to the nearest object's surface. Now that you have configured snapping, press the **Measure Tool Icon** on the -left of the **Viewport** panel. **Click** and **Drag** in the 3D viewport to -take a measurement. A circle will appear guiding the first point of your -measurement. While **Dragging**, press the ``X`` key to lock the measurement -line along the X axis. Alternatively, press the ``Y`` or ``Z`` key to lock the -measurement line along the Y or Z axis. Let go of the mouse to finish your -measurement. +left of the :menuselection:`3D Viewport`. **Click** and **Drag** in the 3D +viewport to take a measurement. A circle will appear guiding the first point of +your measurement. While **Dragging**, press the ``X`` key to lock the +measurement line along the X axis. Alternatively, press the ``Y`` or ``Z`` key +to lock the measurement line along the Y or Z axis. Let go of the mouse to +finish your measurement. .. image:: images/measure-tool.png diff --git a/src/bonsai/docs/quickstart/images/outliner-filter.png b/src/bonsai/docs/quickstart/images/outliner-filter.png deleted file mode 100644 index 08cafb6632..0000000000 Binary files a/src/bonsai/docs/quickstart/images/outliner-filter.png and /dev/null differ diff --git a/src/bonsai/docs/quickstart/images/outliner-isolate.png b/src/bonsai/docs/quickstart/images/outliner-isolate.png deleted file mode 100644 index ee6cc3ff2a..0000000000 Binary files a/src/bonsai/docs/quickstart/images/outliner-isolate.png and /dev/null differ diff --git a/src/bonsai/docs/quickstart/images/outliner.png b/src/bonsai/docs/quickstart/images/outliner.png deleted file mode 100644 index d568f9ed07..0000000000 Binary files a/src/bonsai/docs/quickstart/images/outliner.png and /dev/null differ diff --git a/src/bonsai/docs/quickstart/images/spatial-tree-features.png b/src/bonsai/docs/quickstart/images/spatial-tree-features.png new file mode 100644 index 0000000000..058272b4c3 Binary files /dev/null and b/src/bonsai/docs/quickstart/images/spatial-tree-features.png differ diff --git a/src/bonsai/docs/quickstart/images/spatial-tree.png b/src/bonsai/docs/quickstart/images/spatial-tree.png new file mode 100644 index 0000000000..923c4dcc01 Binary files /dev/null and b/src/bonsai/docs/quickstart/images/spatial-tree.png differ diff --git a/src/bonsai/docs/quickstart/installation.rst b/src/bonsai/docs/quickstart/installation.rst index 058ab525ff..58d3e7b9ec 100644 --- a/src/bonsai/docs/quickstart/installation.rst +++ b/src/bonsai/docs/quickstart/installation.rst @@ -39,4 +39,4 @@ You can enable add-ons permanently by using ``Save User Settings`` from the Addo .. seealso:: - If you are a poweruser, you may be interested in the **Unstable installation** to help with testing. :doc:`Read more ` + If you are a poweruser, you may be interested in the :ref:`guides/development/installation:Unstable installation` to help with testing. diff --git a/src/bonsai/docs/reference/interface.rst b/src/bonsai/docs/reference/interface.rst index 89087450c2..01c21bdb3d 100644 --- a/src/bonsai/docs/reference/interface.rst +++ b/src/bonsai/docs/reference/interface.rst @@ -27,6 +27,10 @@ You can split, merge, or create new panels by clicking :kbd:`RMB` in-between pan You can save panel layouts, or switch to another customised panel layout by clicking the tabs in the :doc:`/reference/topbar`. Bonsai's default layout is stored in the **BIM** tab. +.. tip:: + + Depending on your screen size, you may need to use the mouse wheel to scroll through the workspaces or, if you press the mouse wheel, you can drag the workspaces to see them all. + .. image:: images/interface-tabs.png The :doc:`Topbar ` and :doc:`/reference/status_bar` cannot be customised. diff --git a/src/bonsai/scripts/bonsai_icons.svg b/src/bonsai/scripts/bonsai_icons.svg new file mode 100644 index 0000000000..2bf26bbe10 --- /dev/null +++ b/src/bonsai/scripts/bonsai_icons.svg @@ -0,0 +1,1934 @@ + + + + diff --git a/src/bonsai/scripts/generate_png_icons.py b/src/bonsai/scripts/generate_png_icons.py new file mode 100644 index 0000000000..d24b69fe90 --- /dev/null +++ b/src/bonsai/scripts/generate_png_icons.py @@ -0,0 +1,116 @@ +# the SVG_FILE should be the file that contains all the icons (each icon should be in a unique layer group, and colored for dark mode) +# the EXPORT_FOLDER is where the png files get exported to +# the script will export dark mode version and modify colors for a light mode verion +# requires inkscape to run +# to run on mac: python3 generate_png_icons.py + +import os +import subprocess +import xml.etree.ElementTree as ET +import re + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SVG_FILE = os.path.join(SCRIPT_DIR, "bonsai_icons.svg") +EXPORT_FOLDER = os.path.join(SCRIPT_DIR, "..", "bonsai", "bim", "data", "icons") + +overwrite_all = False +skip_all = False + + +def dark_to_light(svg_content): + svg_content = re.sub(r"#ffffff", "#434343", svg_content, flags=re.IGNORECASE) # white to drak grey + + svg_content = re.sub(r"#00d27b", "#009d5c", svg_content, flags=re.IGNORECASE) # make green darker + svg_content = re.sub(r"#009a5f", "#00663f", svg_content, flags=re.IGNORECASE) # make dark green darker + svg_content = re.sub(r"#75ffaf", "#3dff8f", svg_content, flags=re.IGNORECASE) # make light green darker + + svg_content = re.sub(r"#cce3ff", "#3391ff", svg_content, flags=re.IGNORECASE) # make blue darker + svg_content = re.sub(r"#4c9fff", "#005fcc", svg_content, flags=re.IGNORECASE) # make dark blue darker + svg_content = re.sub(r"#c9e0ff", "#80b8ff", svg_content, flags=re.IGNORECASE) # make light blue darker + + return svg_content + + +def prompt_overwrite(file_path, png_name): + global overwrite_all, skip_all + + if skip_all and os.path.exists(file_path): + return "n" + + if overwrite_all: + return "y" + + if os.path.exists(file_path): + while True: + response = ( + input( + f"File '{png_name}' already exists.\nOverwrite? (Enter = yes, s = skip, y = yes all, n = no all): " + ) + .strip() + .lower() + ) + if response in {"", "y", "n", "s"}: + if response == "y": + overwrite_all = True + elif response == "n": + skip_all = True + return response + else: + print("Invalid option. Please enter Enter, y, n, or s.") + return "" + + +def export_svg_group(group_content, group_label, mode, output_folder): + temp_svg_file = os.path.join(output_folder, f"{group_label}_{mode}.svg") + svg_content = f""" + {group_content} + """ + + with open(temp_svg_file, "w") as file: + file.write(svg_content) + + png_name = f"{mode}_{group_label}.png" + output_png_file = os.path.join(output_folder, png_name) + + user_choice = prompt_overwrite(output_png_file, png_name) + + if user_choice in {"y", ""}: + subprocess.run(["inkscape", temp_svg_file, "--export-type=png", "--export-filename=" + output_png_file]) + print(f"\033[32mExported PNG file: {output_png_file}\033[0m") + + elif user_choice == "n": + print(f"Skipping {png_name}") + + elif user_choice == "s": + print(f"Skipped exporting {output_png_file}.") + + os.remove(temp_svg_file) + + +def export_svg_groups_to_png(svg_file, output_folder): + tree = ET.parse(svg_file) + root = tree.getroot() + ns = {"svg": "http://www.w3.org/2000/svg"} + + for group in root.findall(".//svg:g", ns): + group_label = group.get("{http://www.inkscape.org/namespaces/inkscape}label") + if not group_label: + continue + + style = group.get("style", "") + if "display:none" in style: + style = style.replace("display:none", "display:inline") + elif "display" not in style: + style += ";display:inline" + group.set("style", style) + + group_content = ET.tostring(group, encoding="unicode", method="xml") + export_svg_group(group_content, group_label, "dm", output_folder) + light_mode_svg = dark_to_light(group_content) + export_svg_group(light_mode_svg, group_label, "lm", output_folder) + + +if __name__ == "__main__": + os.makedirs(EXPORT_FOLDER, exist_ok=True) + export_svg_groups_to_png(SVG_FILE, EXPORT_FOLDER) diff --git a/src/bonsai/scripts/get_wheels.py b/src/bonsai/scripts/get_wheels.py index 1fa8daf95c..086dc99775 100644 --- a/src/bonsai/scripts/get_wheels.py +++ b/src/bonsai/scripts/get_wheels.py @@ -2,16 +2,17 @@ import os import sys -def find_whl_files(directory): +def find_whl_files(directory: str) -> list[str]: whl_files = [] for root, dirs, files in os.walk(directory): for file in files: if file.endswith(".whl"): whl_files.append(os.path.relpath(os.path.join(root, file), directory)) + whl_files.sort() return whl_files -def update_pyproject_toml(pyproject_path, whl_files): +def update_pyproject_toml(pyproject_path: str, whl_files: list[str]) -> None: with open(pyproject_path, "a") as f: f.write("\nwheels = [\n") for whl in whl_files: diff --git a/src/bonsai/scripts/installation/dev_environment.sh b/src/bonsai/scripts/installation/dev_environment.sh index 24ee0a7129..39e36ceef0 100644 --- a/src/bonsai/scripts/installation/dev_environment.sh +++ b/src/bonsai/scripts/installation/dev_environment.sh @@ -5,48 +5,50 @@ REPO_PATH="" BLENDER_PATH="" PACKAGE_PATH="${BLENDER_PATH}/extensions/.local/lib/python3.11/site-packages" -BONSAI_PATH="${BLENDER_PATH}/extensions/user_default/bonsai" +# If you are installing offline, use this instead: +# BONSAI_PATH="${BLENDER_PATH}/extensions/user_default/bonsai" +BONSAI_PATH="${BLENDER_PATH}/extensions/raw_githubusercontent_com/bonsai" # Changing to the Git repository directory -cd $REPO_PATH +cd "${REPO_PATH}" # Copy over compiled IfcOpenShell files -cp $PACKAGE_PATH/ifcopenshell/*_wrapper* $PWD/src/ifcopenshell-python/ifcopenshell/ +cp "${PACKAGE_PATH}/ifcopenshell/*_wrapper*" "${PWD}/src/ifcopenshell-python/ifcopenshell/" # Remove extension and link to Git -rm $BONSAI_PATH/__init__.py -rm -r $PACKAGE_PATH/bonsai -rm -r $PACKAGE_PATH/ifcopenshell -rm -r $PACKAGE_PATH/ifccsv.py -rm -r $PACKAGE_PATH/ifcdiff.py -rm -r $PACKAGE_PATH/bsdd.py -rm -r $PACKAGE_PATH/bcf -rm -r $PACKAGE_PATH/ifc4d -rm -r $PACKAGE_PATH/ifc5d -rm -r $PACKAGE_PATH/ifccityjson -rm -r $PACKAGE_PATH/ifcclash -rm -r $PACKAGE_PATH/ifcpatch -rm -r $PACKAGE_PATH/ifctester -rm -r $PACKAGE_PATH/ifcfm +rm "${BONSAI_PATH}/__init__.py" +rm -r "${PACKAGE_PATH}/bonsai" +rm -r "${PACKAGE_PATH}/ifcopenshell" +rm -r "${PACKAGE_PATH}/ifccsv.py" +rm -r "${PACKAGE_PATH}/ifcdiff.py" +rm -r "${PACKAGE_PATH}/bsdd.py" +rm -r "${PACKAGE_PATH}/bcf" +rm -r "${PACKAGE_PATH}/ifc4d" +rm -r "${PACKAGE_PATH}/ifc5d" +rm -r "${PACKAGE_PATH}/ifccityjson" +rm -r "${PACKAGE_PATH}/ifcclash" +rm -r "${PACKAGE_PATH}/ifcpatch" +rm -r "${PACKAGE_PATH}/ifctester" +rm -r "${PACKAGE_PATH}/ifcfm" -ln -s $PWD/src/bonsai/bonsai/__init__.py $BONSAI_PATH/__init__.py -ln -s $PWD/src/bonsai/bonsai $PACKAGE_PATH/bonsai -ln -s $PWD/src/ifcopenshell-python/ifcopenshell $PACKAGE_PATH/ifcopenshell -ln -s $PWD/src/ifccsv/ifccsv.py $PACKAGE_PATH/ifccsv.py -ln -s $PWD/src/ifcdiff/ifcdiff.py $PACKAGE_PATH/ifcdiff.py -ln -s $PWD/src/bsdd/bsdd.py $PACKAGE_PATH/bsdd.py -ln -s $PWD/src/bcf/bcf $PACKAGE_PATH/bcf -ln -s $PWD/src/ifc4d/ifc4d $PACKAGE_PATH/ifc4d -ln -s $PWD/src/ifc5d/ifc5d $PACKAGE_PATH/ifc5d -ln -s $PWD/src/ifccityjson/ifccityjson $PACKAGE_PATH/ifccityjson -ln -s $PWD/src/ifcclash/ifcclash $PACKAGE_PATH/ifcclash -ln -s $PWD/src/ifcpatch/ifcpatch $PACKAGE_PATH/ifcpatch -ln -s $PWD/src/ifctester/ifctester $PACKAGE_PATH/ifctester -ln -s $PWD/src/ifcfm/ifcfm $PACKAGE_PATH/ifcfm +ln -s "${PWD}/src/bonsai/bonsai/__init__.py" "${BONSAI_PATH}/__init__.py" +ln -s "${PWD}/src/bonsai/bonsai" "${PACKAGE_PATH}/bonsai" +ln -s "${PWD}/src/ifcopenshell-python/ifcopenshell" "${PACKAGE_PATH}/ifcopenshell" +ln -s "${PWD}/src/ifccsv/ifccsv.py" "${PACKAGE_PATH}/ifccsv.py" +ln -s "${PWD}/src/ifcdiff/ifcdiff.py" "${PACKAGE_PATH}/ifcdiff.py" +ln -s "${PWD}/src/bsdd/bsdd.py" "${PACKAGE_PATH}/bsdd.py" +ln -s "${PWD}/src/bcf/bcf" "${PACKAGE_PATH}/bcf" +ln -s "${PWD}/src/ifc4d/ifc4d" "${PACKAGE_PATH}/ifc4d" +ln -s "${PWD}/src/ifc5d/ifc5d" "${PACKAGE_PATH}/ifc5d" +ln -s "${PWD}/src/ifccityjson/ifccityjson" "${PACKAGE_PATH}/ifccityjson" +ln -s "${PWD}/src/ifcclash/ifcclash" "${PACKAGE_PATH}/ifcclash" +ln -s "${PWD}/src/ifcpatch/ifcpatch" "${PACKAGE_PATH}/ifcpatch" +ln -s "${PWD}/src/ifctester/ifctester" "${PACKAGE_PATH}/ifctester" +ln -s "${PWD}/src/ifcfm/ifcfm" "${PACKAGE_PATH}/ifcfm" # Manually download some third party dependencies -cd $PACKAGE_PATH/bonsai/bim/data/gantt +cd "${PACKAGE_PATH}/bonsai/bim/data/gantt" wget https://raw.githubusercontent.com/jsGanttImproved/jsgantt-improved/master/dist/jsgantt.js wget https://raw.githubusercontent.com/jsGanttImproved/jsgantt-improved/master/dist/jsgantt.css -cd $PACKAGE_PATH/bonsai/bim/schema +cd "${PACKAGE_PATH}/bonsai/bim/schema" wget https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl diff --git a/src/bonsai/test/bim/feature/drawing.feature b/src/bonsai/test/bim/feature/drawing.feature index 762be4e8ef..f5839dfe8e 100644 --- a/src/bonsai/test/bim/feature/drawing.feature +++ b/src/bonsai/test/bim/feature/drawing.feature @@ -51,6 +51,29 @@ Scenario: Create drawing after deleting a duplicated object When I press "bim.create_drawing" Then nothing happens +Scenario: Activate drawing preserves visibility for non-ifc objects + Given an empty IFC project + And I add a cube + And I add a cube + And the object "Cube" is visible + And the object "Cube.001" is not visible + And I press "bim.add_drawing" + And the variable "drawing" is "IfcStore.get_file().by_type('IfcAnnotation')[0].id()" + And I set "scene.DocProperties.active_drawing_index" to "0" + When I press "bim.activate_drawing(drawing={drawing})" + Then the object "Cube" is visible + And the object "Cube.001" is not visible + +Scenario: Activate drawing preserves selection + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I press "bim.add_drawing" + And the variable "drawing" is "IfcStore.get_file().by_type('IfcAnnotation')[0].id()" + And I set "scene.DocProperties.active_drawing_index" to "0" + When I press "bim.activate_drawing(drawing={drawing})" + Then the object "Cube" is selected + Scenario: Remove drawing Given an empty IFC project And I add a cube diff --git a/src/bonsai/test/bim/feature/geometry.feature b/src/bonsai/test/bim/feature/geometry.feature index efb5e81660..6e12aa0d84 100644 --- a/src/bonsai/test/bim/feature/geometry.feature +++ b/src/bonsai/test/bim/feature/geometry.feature @@ -326,7 +326,19 @@ Scenario: Override duplicate move - with active IFC data And the object "IfcWall/Cube.001" exists And the object "IfcWall/Cube.001" is an "IfcWall" And the object "IfcWall/Cube.001" has a "Tessellation" representation of "Model/Body/MODEL_VIEW" - And the object "IfcBuildingStorey/My Storey.001" exists + And the object "IfcBuildingStorey/My Storey.001" does not exist + +Scenario: Override duplicate move - with unlocked elements + 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" + And the object "IfcBuildingStorey/My Storey" is selected + And I set "scene.BIMSpatialDecompositionProperties.is_locked" to "False" + When I duplicate the selected objects + Then the object "IfcBuildingStorey/My Storey.001" exists And the object "IfcBuildingStorey/My Storey.001" is an "IfcBuildingStorey" Scenario: Override duplicate move - copying a coloured representation diff --git a/src/bonsai/test/bim/feature/light.feature b/src/bonsai/test/bim/feature/light.feature index 86dbdbf633..831d6f25bb 100644 --- a/src/bonsai/test/bim/feature/light.feature +++ b/src/bonsai/test/bim/feature/light.feature @@ -5,7 +5,6 @@ Scenario: Viewing default solar settings Given an empty IFC project And I look at the "Solar Access / Shadow" panel Then I see "Etc/GMT" - And I see "Sunrise: 06:02:50" Scenario: Changing the month Given an empty IFC project @@ -33,7 +32,7 @@ Scenario: Automatic timezone detection based on lat / long When I set the "Latitude" property to "10.0" And I set the "Longitude" property to "20.0" Then I see "Africa/Ndjamena" - And I see "Sunrise: 05:29:43" + And I see "Sunrise: 05:56:42" Scenario: Display the sun path Given an empty IFC project diff --git a/src/bonsai/test/bim/feature/misc.feature b/src/bonsai/test/bim/feature/misc.feature index b4c76c3401..d418ee3ea5 100644 --- a/src/bonsai/test/bim/feature/misc.feature +++ b/src/bonsai/test/bim/feature/misc.feature @@ -27,7 +27,7 @@ Scenario: Resize to storey And the object "IfcWall/Cube" is selected And the variable "storey" is "tool.Ifc.get().by_type('IfcBuildingStorey')[0].id()" And I press "bim.set_default_container(container={storey})" - And I press "bim.assign_container()" + And I press "bim.assign_container(container={storey})" When I press "bim.resize_to_storey(total_storeys=1)" Then nothing happens diff --git a/src/bonsai/test/bim/feature/root.feature b/src/bonsai/test/bim/feature/root.feature index ee26ac39ed..d9606b62a0 100644 --- a/src/bonsai/test/bim/feature/root.feature +++ b/src/bonsai/test/bim/feature/root.feature @@ -110,10 +110,17 @@ Scenario: Copy a wall And I duplicate the selected objects Then the object "IfcWall/Cube" and "IfcWall/Cube.001" are different elements -Scenario: Copy a storey +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 the object "IfcBuildingStorey/My Storey.001" does not exist + +Scenario: Copy a storey - when unlocked + Given an empty IFC project + And the object "IfcBuildingStorey/My Storey" is selected + And I set "scene.BIMSpatialDecompositionProperties.is_locked" to "False" + When I duplicate the selected objects Then the object "IfcBuildingStorey/My Storey" and "IfcBuildingStorey/My Storey.001" are different elements And the object "IfcBuildingStorey/My Storey" is in the collection "IfcBuildingStorey/My Storey" And the object "IfcBuildingStorey/My Storey.001" is in the collection "IfcBuildingStorey/My Storey.001" diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 23d28a8805..682bbefce4 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -33,7 +33,7 @@ from pytest_bdd import scenarios, given, when, then, parsers from mathutils import Vector from math import radians from pathlib import Path -from typing import Union +from typing import Union, Any scenarios("feature") @@ -49,18 +49,18 @@ webbrowser.open = lambda x: True class PanelSpy: - def __init__(self, panel): + def __init__(self, panel: type[bpy.types.Panel]): self.is_spy_dirty = True self.panel = panel def refresh_spy(self): if self.is_spy_dirty: self.is_spy_dirty = False - self.spied_attr = None - self.spied_labels = [] - self.spied_props = [] - self.spied_operators = [] - self.spied_lists = [] + self.spied_attr: Union[str, None] = None + self.spied_labels: list[str] = [] + self.spied_props: list[dict[str, Any]] = [] + self.spied_operators: list[dict[str, Any]] = [] + self.spied_lists: list[dict[str, Any]] = [] self.panel.draw(self, bpy.context) def __getattr__(self, attr): @@ -91,6 +91,7 @@ class PanelSpy: return self elif self.spied_attr == "prop": props, name = args + props: bpy.types.bpy_struct text = kwargs.get("text", props.bl_rna.properties[name].name) icon = kwargs.get("icon", None) prop_type = props.bl_rna.properties[name].type @@ -152,7 +153,7 @@ class TemplateListSpy: panel_name_cache = {} -panel_spy = None +panel_spy: PanelSpy = None def replace_variables(value): @@ -307,7 +308,7 @@ def i_set_the_prop_property_to_value(prop, value): 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"] == "INTEGER": + 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] @@ -535,7 +536,6 @@ def i_deselect_all_objects(): @given(parsers.parse('the object "{name}" is selected')) @when(parsers.parse('the object "{name}" is selected')) -@then(parsers.parse('the object "{name}" is selected')) def the_object_name_is_selected(name): i_deselect_all_objects() additionally_the_object_name_is_selected(name) @@ -839,6 +839,30 @@ def the_object_name_is_not_a_void(name): assert False, "A void was found" +@given(parsers.parse('the object "{name}" is visible')) +def given_the_object_name_is_visible(name): + obj = the_object_name_exists(name) + obj.hide_set(False) + + +@given(parsers.parse('the object "{name}" is not visible')) +def given_the_object_name_is_not_visible(name): + obj = the_object_name_exists(name) + obj.hide_set(True) + + +@then(parsers.parse('the object "{name}" is visible')) +def the_object_name_is_visible(name): + obj = the_object_name_exists(name) + assert obj.hide_get() == False + + +@then(parsers.parse('the object "{name}" is not visible')) +def the_object_name_is_not_visible(name): + obj = the_object_name_exists(name) + assert obj.hide_get() == True + + @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() diff --git a/src/bonsai/test/core/test_material.py b/src/bonsai/test/core/test_material.py index f95ddea746..a8baee0c6c 100644 --- a/src/bonsai/test/core/test_material.py +++ b/src/bonsai/test/core/test_material.py @@ -49,6 +49,7 @@ class TestAddMaterialSet: ifc.run("material.add_material_set", name="Unnamed", set_type="set_type").should_be_called().will_return( "material" ) + material.ensure_new_material_set_is_valid("material").should_be_called() material.is_editing_materials().should_be_called().will_return(False) assert subject.add_material_set(ifc, material, set_type="set_type") == "material" @@ -56,6 +57,7 @@ class TestAddMaterialSet: ifc.run("material.add_material_set", name="Unnamed", set_type="set_type").should_be_called().will_return( "material" ) + material.ensure_new_material_set_is_valid("material").should_be_called() material.is_editing_materials().should_be_called().will_return(True) material.get_active_material_type().should_be_called().will_return("material_type") material.import_material_definitions("material_type").should_be_called() diff --git a/src/bonsai/test/core/test_pset.py b/src/bonsai/test/core/test_pset.py index 39f0dde47d..9ebf82bb8d 100644 --- a/src/bonsai/test/core/test_pset.py +++ b/src/bonsai/test/core/test_pset.py @@ -60,3 +60,11 @@ class TestCopyPropertyToSelection: subject.copy_property_to_selection( ifc, pset, is_pset=False, obj="obj", pset_name="qto_name", prop_name="prop_name", prop_value="prop_value" ) + + +class TestUnsharePset: + def test_run(self, ifc, pset): + ifc.get_entity_by_id("pset_id").should_be_called().will_return("pset") + pset.get_selected_pset_elements("obj_name", "obj_type", "pset").should_be_called().will_return("elements") + ifc.run("pset.unshare_pset", products="elements", pset="pset").should_be_called() + subject.unshare_pset(ifc, pset, "obj_type", "obj_name", "pset_id") diff --git a/src/bonsai/test/tool/test_classification.py b/src/bonsai/test/tool/test_classification.py new file mode 100644 index 0000000000..5d094bf0bc --- /dev/null +++ b/src/bonsai/test/tool/test_classification.py @@ -0,0 +1,130 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2022 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 os +import bpy +import ifcopenshell +import ifcopenshell.util.classification +import ifcopenshell.util.element +import bonsai.core.tool +import bonsai.tool as tool +from test.bim.bootstrap import NewFile +from bonsai.tool.classification import Classification as subject + + +class TestImplementsTool(NewFile): + def test_run(self): + assert isinstance(subject(), bonsai.core.tool.Classification) + + +class TestAddClassificationReferenceFromBSDD(NewFile): + def test_add_classification_reference(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"] + bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + element = tool.Ifc.get_entity(obj) + assert element + + bpy.ops.bim.load_bsdd_domains() + uri = "https://identifier.buildingsmart.org/uri/molio/cciconstruction/1.0" + bpy.ops.bim.set_active_bsdd_domain(name="CCI Construction", uri=uri) + props = context.scene.BIMBSDDProperties + bpy.context.scene.BIMClassificationProperties.classification_source = "BSDD" + props.should_filter_ifc_class = True + props.keyword = "Room" + bpy.ops.bim.search_bsdd_classifications() + props.active_classification_index = next(i for i, c in enumerate(props.classifications) if c.name == "Room") + bpy.ops.bim.add_classification_reference_from_bsdd(obj="IfcSpace/Cube", obj_type="Object") + refs = ifcopenshell.util.classification.get_references(element) + assert len(refs) == 1 + assert list(refs)[0].Location.startswith(uri) + + def test_add_classification_refence_with_props(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"] + bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + element = tool.Ifc.get_entity(obj) + assert element + + bpy.ops.bim.load_bsdd_domains() + uri = "https://identifier.buildingsmart.org/uri/molio/cciconstruction/1.0" + bpy.ops.bim.set_active_bsdd_domain(name="CCI Construction", uri=uri) + props = context.scene.BIMBSDDProperties + bpy.context.scene.BIMClassificationProperties.classification_source = "BSDD" + props.should_filter_ifc_class = True + props.keyword = "Room" + bpy.ops.bim.search_bsdd_classifications() + # https://identifier.buildingsmart.org/uri/molio/cciconstruction/1.0/class/A-AAA + props.active_classification_index = next(i for i, c in enumerate(props.classifications) if c.name == "Room") + bpy.ops.bim.get_bsdd_classification_properties() + psets = props.classification_psets + assert len(psets) == 1 + pset = psets[0] + assert pset.name == "Pset_SpaceCommon" + assert len(pset.properties) == 1 + pset_prop = pset.properties[0] + assert pset_prop.name == "HandicapAccessible" + 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 + 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"] + bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + 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 + bpy.context.scene.BIMClassificationProperties.classification_source = "BSDD" + props.should_filter_ifc_class = False # Important due to class mismatch. + props.keyword = "Check-In Conveyor" + bpy.ops.bim.search_bsdd_classifications() + # https://identifier.buildingsmart.org/uri/ifcairport/ifcairport/1.0/class/ifcairport0000000004 + props.active_classification_index = next( + 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 + pset_prop = pset.properties[0] + assert pset_prop.name == "ObjectType" + + bpy.ops.bim.add_classification_reference_from_bsdd(obj="IfcSpace/Cube", obj_type="Object") + assert element.ObjectType == "CHECKINCONVEYOR" + assert not ifcopenshell.util.element.get_psets(element) diff --git a/src/bonsai/test/tool/test_debug.py b/src/bonsai/test/tool/test_debug.py index 7f4c71f0e3..582b1a63ef 100644 --- a/src/bonsai/test/tool/test_debug.py +++ b/src/bonsai/test/tool/test_debug.py @@ -57,5 +57,12 @@ class TestPurgeHdf5Cache(NewFile): test_file = cache_dir / "test.h5" test_file.parent.mkdir(parents=True, exist_ok=True) test_file.touch() + + # Ensure it can skip currently loaded cache. + loaded_file_path = test_file.with_stem("test_loaded") + loaded_file = open(loaded_file_path, "w") + subject.purge_hdf5_cache() - assert not [f for f in cache_dir.iterdir() if f.suffix == ".h5"] + # On Unix loaded files are not locked. + paths = [loaded_file_path] if os.name == "nt" else [] + assert [f for f in cache_dir.iterdir() if f.suffix == ".h5"] == paths diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index ac522ab8cf..241588cc3a 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -69,11 +69,13 @@ class TestCreateSvgSheet(NewFile): ifc = ifcopenshell.file() ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") tool.Ifc.set(ifc) + ifc_path = Path("test/files/temp/test.ifc").absolute() + bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True) document = ifc.createIfcDocumentInformation( Identification="X", Name="FOOBAR", - Scope="DOCUMENTATION", - Location=os.path.join(bpy.context.scene.BIMProperties.data_dir, "cache", "X - FOOBAR.svg"), + Scope="SHEET", + Location=(ifc_path.parent / "layouts" / "X - FOOBAR.svg").as_posix(), ) uri = subject.create_svg_sheet(document, "A1") assert uri.endswith(".svg") @@ -218,18 +220,18 @@ class TestEnsureUniqueIdentification(NewFile): ifc = ifcopenshell.file() tool.Ifc.set(ifc) assert subject.ensure_unique_identification("FOOBAR") == "FOOBAR" - ifc.createIfcDocumentInformation(Identification="FOOBAR", Scope="DOCUMENTATION") + ifc.createIfcDocumentInformation(Identification="FOOBAR", Scope="SHEET") assert subject.ensure_unique_identification("FOOBAR") == "FOOBAR-X" - ifc.createIfcDocumentInformation(Identification="FOOBAR-X", Scope="DOCUMENTATION") + ifc.createIfcDocumentInformation(Identification="FOOBAR-X", Scope="SHEET") assert subject.ensure_unique_identification("FOOBAR") == "FOOBAR-X-X" def test_unique_document_id_ifc2x3(self): ifc = ifcopenshell.file(schema="IFC2X3") tool.Ifc.set(ifc) assert subject.ensure_unique_identification("FOOBAR") == "FOOBAR" - ifc.createIfcDocumentInformation(DocumentId="FOOBAR", Scope="DOCUMENTATION") + ifc.createIfcDocumentInformation(DocumentId="FOOBAR", Scope="SHEET") assert subject.ensure_unique_identification("FOOBAR") == "FOOBAR-X" - ifc.createIfcDocumentInformation(DocumentId="FOOBAR-X", Scope="DOCUMENTATION") + ifc.createIfcDocumentInformation(DocumentId="FOOBAR-X", Scope="SHEET") assert subject.ensure_unique_identification("FOOBAR") == "FOOBAR-X-X" diff --git a/src/bonsai/test/tool/test_loader.py b/src/bonsai/test/tool/test_loader.py index 7fa66955d4..c97ebef955 100644 --- a/src/bonsai/test/tool/test_loader.py +++ b/src/bonsai/test/tool/test_loader.py @@ -19,6 +19,7 @@ import bpy import bmesh import ifcopenshell +import ifcopenshell.util.schema import bonsai.core.tool import bonsai.tool as tool from test.bim.bootstrap import NewFile @@ -391,12 +392,11 @@ class TestCreatingStyles(NewFile): ), f"Failed to match pixels for {n_components}.\nBlender pixel_data: {image_node.image.pixels[:]}.\nExpected data: {expected_pixel_data}" -class TestLoadingIndexedTextureMap(NewFile): - def test_run(self): +class TestLoadingIndexedMap(NewFile): + def test_load_texture_map(self): bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" bpy.ops.bim.create_project() - # TODO: replace with loading geometry from IFC # create a cube without uv and triangulate it mesh = bpy.data.meshes.new("Cube") bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) @@ -408,31 +408,7 @@ class TestLoadingIndexedTextureMap(NewFile): builder = ShapeBuilder(ifc_file) # tesselated cube - points = ( - V(-1000, -1000, -1000), - V(-1000, -1000, 1000), - V(-1000, 1000, -1000), - V(-1000, 1000, 1000), - V(1000, -1000, -1000), - V(1000, -1000, 1000), - V(1000, 1000, -1000), - V(1000, 1000, 1000), - ) - faces = ( - (1, 2, 0), - (3, 6, 2), - (7, 4, 6), - (5, 0, 4), - (6, 0, 2), - (3, 5, 7), - (1, 3, 2), - (3, 7, 6), - (7, 5, 4), - (5, 1, 0), - (6, 4, 0), - (3, 1, 5), - ) - face_set = builder.polygonal_face_set(points, faces) + face_set = builder.polygonal_face_set([v.co for v in mesh.vertices], [p.vertices[:] for p in mesh.polygons]) uv_indices = ( (1, 2, 3), @@ -493,9 +469,78 @@ class TestLoadingIndexedTextureMap(NewFile): texture_coord.TexCoordIndex = uv_indices texture_coord.TexCoords = uv_verts_list - subject.load_indexed_texture_map(texture_coord, mesh) + subject.load_indexed_map(texture_coord, mesh) uv_layer = mesh.uv_layers.active assert uv_layer is not None for ifc_uv, blender_uv in zip(uv_verts, uv_layer.uv, strict=True): assert tool.Cad.are_vectors_equal(Vector(ifc_uv), blender_uv.vector) + + def test_load_color_map(self): + bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" + bpy.ops.bim.create_project() + + # create a cube without uv and triangulate it + mesh = bpy.data.meshes.new("Cube") + bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) + bmesh.ops.create_cube(bm, size=2.0, calc_uvs=False) + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + tool.Blender.apply_bmesh(mesh, bm) + + ifc_file = tool.Ifc.get() + builder = ShapeBuilder(ifc_file) + + # tesselated cube + face_set = builder.polygonal_face_set([v.co for v in mesh.vertices], [p.vertices[:] for p in mesh.polygons]) + + colors = ((1.0, 0.0, 0.0), (0.0, 0.5, 0.0), (1.0, 1.0, 0.0)) + color_index = (0, 0, 0, 0, 2, 1, 0, 0, 0, 0, 2, 1) + + ifc_colors_list = ifc_file.create_entity("IfcColourRgbList", colors) + colour_map = ifc_file.create_entity("IfcIndexedColourMap") + colour_map.MappedTo = face_set + colour_map.Colours = ifc_colors_list + colour_map.ColourIndex = [i + 1 for i in color_index] + + subject.load_indexed_map(colour_map, mesh) + + layer = mesh.color_attributes.active_color + assert layer is not None + for face_i, face in enumerate(mesh.polygons): + for loop_i in face.loop_indices: + blender_color = Vector(layer.data[loop_i].color) + ifc_color = Vector(colors[color_index[face_i]] + (1.0,)) + assert tool.Cad.are_vectors_equal(blender_color, ifc_color) + + +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_ + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + name = "CCI Construction" + base_uri = "https://identifier.buildingsmart.org/uri/molio/cciconstruction/1.0" + if schema == "IFC2X3": + uri = "https://identifier.buildingsmart.org/uri/molio/cciconstruction/1.0/class/A-BDC" + classification = ifc_file.create_entity("IfcClassification", Name=name) + ifc_file.create_entity("IfcClassificationReference", Location=uri, ReferencedSource=classification) + else: + attr_name = "Specification" if schema == "IFC4X3" else "Location" + classification = ifc_file.create_entity("IfcClassification", Name=name) + setattr(classification, attr_name, base_uri) + filepath = "test/files/temp/test.ifc" + ifc_file.write(filepath) + bpy.ops.bim.load_project(filepath=filepath) + props = bpy.context.scene.BIMBSDDProperties + assert props.active_domain == name + assert props.active_uri == base_uri + + def test_set_load_and_set_active_bsdd_ifc2x3(self): + self.run_test("IFC2X3") + + def test_set_load_and_set_active_bsdd_ifc4(self): + self.run_test("IFC4") + + def test_set_load_and_set_active_bsdd_ifc4x3(self): + self.run_test("IFC4X3") diff --git a/src/bonsai/test/tool/test_material.py b/src/bonsai/test/tool/test_material.py index 8c2357c5c7..281c8918ac 100644 --- a/src/bonsai/test/tool/test_material.py +++ b/src/bonsai/test/tool/test_material.py @@ -19,6 +19,9 @@ import bpy import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.material +import ifcopenshell.api.pset +import ifcopenshell.api.style import bonsai.core.tool import bonsai.tool as tool from test.bim.bootstrap import NewFile @@ -150,3 +153,52 @@ class TestIsMaterialUsedInSets(NewFile): material_set.MaterialLayers = [material_set_item] material_set_item.Material = material assert subject.is_material_used_in_sets(material) is True + + +class TestEnsureNewMaterialSetIsValid(NewFile): + def test_material_constituent_set(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material_set = ifc.create_entity("IfcMaterialConstituentSet") + subject.ensure_new_material_set_is_valid(material_set) + assert len(constituents := material_set.MaterialConstituents) == 1 + assert constituents[0].Material + + def test_material_layer_set(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material_set = ifc.create_entity("IfcMaterialLayerSet") + subject.ensure_new_material_set_is_valid(material_set) + assert len(layers := material_set.MaterialLayers) == 1 + assert layers[0].Material + + def test_material_profile_set(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material_set = ifc.create_entity("IfcMaterialProfileSet") + subject.ensure_new_material_set_is_valid(material_set) + assert len(profiles := material_set.MaterialProfiles) == 1 + assert profiles[0].Profile + + def test_material_list(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material_set = ifc.create_entity("IfcMaterialList") + subject.ensure_new_material_set_is_valid(material_set) + assert len(material_set.Materials) == 1 + + +class TestPurgeUnusedMaterials(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material = ifcopenshell.api.material.add_material(ifc) + # Add pset. + ifcopenshell.api.pset.add_pset(ifc, material, "Foo") + # Add style. + style = ifcopenshell.api.style.add_style(ifc) + context = ifc.create_entity("IfcRepresentationContext") + ifcopenshell.api.style.assign_material_style(ifc, material, style, context) + + assert subject.purge_unused_materials() == 1 + assert not ifc.by_type("IfcMaterial") diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index 0fb327ccdf..17aaf8b3ac 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -196,3 +196,45 @@ class TestRecentIFCProjects(NewFile): subject.add_recent_ifc_project(ifc_file) assert filepath.stat().st_size != 0 assert subject.get_recent_ifc_projects() == [ifc_file] + + +class TestLoadProject(NewFile): + def test_load_project_and_start_fresh_sesion(self): + filepath = Path("test/files/basic.ifc") + bpy.ops.mesh.primitive_monkey_add() + monkey = bpy.context.object + assert monkey + bpy.ops.bim.load_project(filepath=filepath.as_posix()) + assert tool.Ifc.get() + assert not tool.Blender.is_valid_data_block(monkey) + + def test_load_project_without_starting_fresh_sesion(self): + filepath = Path("test/files/basic.ifc") + bpy.ops.mesh.primitive_monkey_add() + monkey = bpy.context.object + assert monkey + bpy.ops.bim.load_project(filepath=filepath.as_posix(), should_start_fresh_session=False) + assert tool.Ifc.get() + assert tool.Blender.is_valid_data_block(monkey) + + def test_load_project_without_ifc_data(self): + filepath = Path("test/files/basic.ifc") + bpy.ops.mesh.primitive_monkey_add() + monkey = bpy.context.object + assert monkey + bpy.ops.bim.load_project(filepath=filepath.as_posix(), import_without_ifc_data=True) + assert not tool.Ifc.get() + assert bpy.data.objects["IfcWall/Wall"] + assert not tool.Blender.is_valid_data_block(monkey) + + def test_load_project_without_ifc_data_and_restarting_session(self): + filepath = Path("test/files/basic.ifc") + bpy.ops.mesh.primitive_monkey_add() + monkey = bpy.context.object + assert monkey + bpy.ops.bim.load_project( + filepath=filepath.as_posix(), import_without_ifc_data=True, should_start_fresh_session=False + ) + assert not tool.Ifc.get() + assert bpy.data.objects["IfcWall/Wall"] + assert tool.Blender.is_valid_data_block(monkey) diff --git a/src/bonsai/test/tool/test_root.py b/src/bonsai/test/tool/test_root.py index 235ed3b04e..dda03e1ed3 100644 --- a/src/bonsai/test/tool/test_root.py +++ b/src/bonsai/test/tool/test_root.py @@ -173,7 +173,7 @@ class TestSetObjectName(NewFile): class TestReassignClass(NewFile): - def test_run(self): + def test_reassigning_multiple_occurrences_of_the_same_type(self): bpy.context.scene.BIMProjectProperties.template_file = "IFC4 Demo Template.ifc" bpy.ops.bim.create_project() ifc_file = tool.Ifc.get() diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 3faae5ff3d..3b0674dd8c 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -164,7 +164,7 @@ class TestGetDecomposedElements(NewFile): site = ifc.createIfcSite() wall = ifc.createIfcWall() ifcopenshell.api.run("spatial.assign_container", ifc, products=[wall], relating_structure=site) - assert subject.get_decomposed_elements(site) == [wall] + assert subject.get_decomposed_elements(site) == {wall} class TestGetObjectMatrix(NewFile): diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index d27eacd0d0..d770b3c38b 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -24,6 +24,7 @@ import requests import webbrowser import http.server from typing import TypedDict, Literal, Optional +from typing_extensions import NotRequired __version__ = version = "0.0.0" @@ -31,340 +32,369 @@ __version__ = version = "0.0.0" Status = Literal["Preview", "Active", "Inactive"] ClassTypes = Literal["Class", "GroupOfProperties", "AlternativeUse", "Material"] -# NOTE: Some values in TypedDicts are actually not guaranteed to be provided -# and should be typed with NotRequired. - +# Use yml_to_classes.py to update classes. class DictionaryContractV1(TypedDict): + code: str uri: str name: str version: str organizationCodeOwner: str organizationNameOwner: str defaultLanguageCode: str - license: str - licenseUrl: str - qualityAssuranceProcedure: str - qualityAssuranceProcedureUrl: str - status: str - moreInfoUrl: str - releaseDate: str - lastUpdatedUtc: str + isLatestVersion: bool + isVerified: bool + license: NotRequired[str] + licenseUrl: NotRequired[str] + qualityAssuranceProcedure: NotRequired[str] + qualityAssuranceProcedureUrl: NotRequired[str] + status: NotRequired[str] + moreInfoUrl: NotRequired[str] + releaseDate: NotRequired[str] + lastUpdatedUtc: NotRequired[str] + availableLanguages: NotRequired[list[CodeNameDto]] class DictionaryResponseContractV1(TypedDict): - totalCount: int - offset: int - count: int - dictionaries: list[DictionaryContractV1] + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + dictionaries: NotRequired[list[DictionaryContractV1]] class ClassListItemContractV1(TypedDict): - uri: str - code: str - name: str - classType: str - referenceCode: str - parentClassCode: str - children: list[ClassListItemContractV1] + uri: NotRequired[str] + code: NotRequired[str] + name: NotRequired[str] + classType: NotRequired[str] + referenceCode: NotRequired[str] + parentClassCode: NotRequired[str] + descriptionPart: NotRequired[str] + children: NotRequired[list[ClassListItemContractV1]] class DictionaryClassesResponseContractV1(TypedDict): + code: str uri: str name: str version: str organizationCodeOwner: str organizationNameOwner: str defaultLanguageCode: str - license: str - licenseUrl: str - qualityAssuranceProcedure: str - qualityAssuranceProcedureUrl: str - status: str - moreInfoUrl: str - releaseDate: str - lastUpdatedUtc: str - classes: list[ClassListItemContractV1] - classesTotalCount: int - classesOffset: int - classesCount: int + isLatestVersion: bool + isVerified: bool + license: NotRequired[str] + licenseUrl: NotRequired[str] + qualityAssuranceProcedure: NotRequired[str] + qualityAssuranceProcedureUrl: NotRequired[str] + status: NotRequired[str] + moreInfoUrl: NotRequired[str] + releaseDate: NotRequired[str] + lastUpdatedUtc: NotRequired[str] + classes: NotRequired[list[ClassListItemContractV1]] + classesTotalCount: NotRequired[int] + classesOffset: NotRequired[int] + classesCount: NotRequired[int] class PropertyListItemContractV1(TypedDict): - uri: str - code: str - name: str + uri: NotRequired[str] + code: NotRequired[str] + name: NotRequired[str] + descriptionPart: NotRequired[str] class DictionaryPropertiesResponseContractV1(TypedDict): + code: str uri: str name: str version: str organizationCodeOwner: str organizationNameOwner: str defaultLanguageCode: str - license: str - licenseUrl: str - qualityAssuranceProcedure: str - qualityAssuranceProcedureUrl: str - status: str - moreInfoUrl: str - releaseDate: str - lastUpdatedUtc: str - properties: list[PropertyListItemContractV1] - propertiesTotalCount: int - propertiesOffset: int - propertiesCount: int + isLatestVersion: bool + isVerified: bool + license: NotRequired[str] + licenseUrl: NotRequired[str] + qualityAssuranceProcedure: NotRequired[str] + qualityAssuranceProcedureUrl: NotRequired[str] + status: NotRequired[str] + moreInfoUrl: NotRequired[str] + releaseDate: NotRequired[str] + lastUpdatedUtc: NotRequired[str] + properties: NotRequired[list[PropertyListItemContractV1]] + propertiesTotalCount: NotRequired[int] + propertiesOffset: NotRequired[int] + propertiesCount: NotRequired[int] class ClassReferenceContractV1(TypedDict): uri: str - name: str - code: str + name: NotRequired[str] + code: NotRequired[str] class ClassPropertyValueContractV1(TypedDict): - uri: str - code: str + uri: NotRequired[str] + code: NotRequired[str] value: str - description: str - sortNumber: int + description: NotRequired[str] + sortNumber: NotRequired[int] class ClassPropertyContractV1(TypedDict): name: str - uri: str - description: str - dataType: str - dimension: str - dimensionLength: int - dimensionMass: int - dimensionTime: int - dimensionElectricCurrent: int - dimensionThermodynamicTemperature: int - dimensionAmountOfSubstance: int - dimensionLuminousIntensity: int - dynamicParameterPropertyCodes: list[str] - example: str - isDynamic: bool - isRequired: bool - isWritable: bool - maxExclusive: float - maxInclusive: float - minExclusive: float - minInclusive: float - pattern: str - physicalQuantity: str - allowedValues: list[ClassPropertyValueContractV1] - predefinedValue: str - propertyCode: str - propertyDictionaryName: str - propertyUri: str - propertySet: str - propertyStatus: str - propertyValueKind: str - symbol: str - units: list[str] - qudtCodes: list[str] + uri: NotRequired[str] + description: NotRequired[str] + definition: NotRequired[str] + dataType: NotRequired[str] + dimension: NotRequired[str] + dimensionLength: NotRequired[int] + dimensionMass: NotRequired[int] + dimensionTime: NotRequired[int] + dimensionElectricCurrent: NotRequired[int] + dimensionThermodynamicTemperature: NotRequired[int] + dimensionAmountOfSubstance: NotRequired[int] + dimensionLuminousIntensity: NotRequired[int] + dynamicParameterPropertyCodes: NotRequired[list[str]] + example: NotRequired[str] + isDynamic: NotRequired[bool] + isRequired: NotRequired[bool] + isWritable: NotRequired[bool] + maxExclusive: NotRequired[float] + maxInclusive: NotRequired[float] + minExclusive: NotRequired[float] + minInclusive: NotRequired[float] + pattern: NotRequired[str] + physicalQuantity: NotRequired[str] + allowedValues: NotRequired[list[ClassPropertyValueContractV1]] + predefinedValue: NotRequired[str] + propertyCode: NotRequired[str] + propertyDictionaryName: NotRequired[str] + propertyDictionaryUri: NotRequired[str] + propertyUri: NotRequired[str] + propertySet: NotRequired[str] + propertyStatus: NotRequired[str] + propertyValueKind: NotRequired[str] + symbol: NotRequired[str] + units: NotRequired[list[str]] + qudtCodes: NotRequired[list[str]] class PropertyContractV4(TypedDict): - dictionaryUri: str + dictionaryUri: NotRequired[str] activationDateUtc: str code: str - creatorLanguageCode: str - countriesOfUse: list[str] - countryOfOrigin: str - deActivationDateUtc: str - definition: str - deprecationExplanation: str - description: str - documentReference: str + creatorLanguageCode: NotRequired[str] + countriesOfUse: NotRequired[list[str]] + countryOfOrigin: NotRequired[str] + deActivationDateUtc: NotRequired[str] + definition: NotRequired[str] + deprecationExplanation: NotRequired[str] + description: NotRequired[str] + documentReference: NotRequired[str] name: str uri: str - replacedObjectCodes: list[str] - replacingObjectCodes: list[str] - revisionDateUtc: str - revisionNumber: int + replacedObjectCodes: NotRequired[list[str]] + replacingObjectCodes: NotRequired[list[str]] + revisionDateUtc: NotRequired[str] + revisionNumber: NotRequired[int] status: str - subdivisionsOfUse: list[str] - uid: str + subdivisionsOfUse: NotRequired[list[str]] + uid: NotRequired[str] versionDateUtc: str - versionNumber: int - visualRepresentationUri: str - connectedPropertyCodes: list[str] - dataType: str - dimension: str - dimensionLength: int - dimensionMass: int - dimensionTime: int - dimensionElectricCurrent: int - dimensionThermodynamicTemperature: int - dimensionAmountOfSubstance: int - dimensionLuminousIntensity: int - dynamicParameterPropertyCodes: list[str] - example: str - isDynamic: bool - maxExclusive: float - maxInclusive: float - methodOfMeasurement: str - minExclusive: float - minInclusive: float - pattern: str - physicalQuantity: str - allowedValues: list[PropertyValueContractV4] - propertyValueKind: str - propertyRelations: list[PropertyRelationContractV4] - textFormat: str - units: list[str] - qudtCodes: list[str] - propertyClasses: list[PropertyClassContractV4] + versionNumber: NotRequired[int] + visualRepresentationUri: NotRequired[str] + connectedPropertyCodes: NotRequired[list[str]] + dataType: NotRequired[str] + dimension: NotRequired[str] + dimensionLength: NotRequired[int] + dimensionMass: NotRequired[int] + dimensionTime: NotRequired[int] + dimensionElectricCurrent: NotRequired[int] + dimensionThermodynamicTemperature: NotRequired[int] + dimensionAmountOfSubstance: NotRequired[int] + dimensionLuminousIntensity: NotRequired[int] + dynamicParameterPropertyCodes: NotRequired[list[str]] + example: NotRequired[str] + isDynamic: NotRequired[bool] + maxExclusive: NotRequired[float] + maxInclusive: NotRequired[float] + methodOfMeasurement: NotRequired[str] + minExclusive: NotRequired[float] + minInclusive: NotRequired[float] + pattern: NotRequired[str] + physicalQuantity: NotRequired[str] + allowedValues: NotRequired[list[PropertyValueContractV4]] + propertyValueKind: NotRequired[str] + propertyRelations: NotRequired[list[PropertyRelationContractV4]] + textFormat: NotRequired[str] + units: NotRequired[list[str]] + qudtCodes: NotRequired[list[str]] + propertyClasses: NotRequired[list[PropertyClassContractV4]] class PropertyValueContractV4(TypedDict): - uri: str - code: str + uri: NotRequired[str] + code: NotRequired[str] value: str - description: str - sortNumber: int + description: NotRequired[str] + sortNumber: NotRequired[int] class PropertyRelationContractV4(TypedDict): - uri: str - relationType: str - relatedPropertyUri: str - relatedPropertyName: str + uri: NotRequired[str] + relationType: NotRequired[str] + relatedPropertyUri: NotRequired[str] + relatedPropertyName: NotRequired[str] class PropertyClassContractV4(TypedDict): - uri: str - code: str + uri: NotRequired[str] + code: NotRequired[str] name: str - definition: str - description: str - propertySet: str + definition: NotRequired[str] + description: NotRequired[str] + propertySet: NotRequired[str] class ClassRelationContractV1(TypedDict): - uri: str + uri: NotRequired[str] relationType: str relatedClassUri: str - relatedClassName: str - fraction: float + relatedClassName: NotRequired[str] + fraction: NotRequired[float] class TextSearchResponseContractV1(TypedDict): - totalCount: int - offset: int - count: int - classes: list[TextSearchResponseClassContractV1] - dictionaries: list[TextSearchResponseDictionaryContractV1] - properties: list[TextSearchResponsePropertyContractV1] + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + classes: NotRequired[list[TextSearchResponseClassContractV1]] + dictionaries: NotRequired[list[TextSearchResponseDictionaryContractV1]] + properties: NotRequired[list[TextSearchResponsePropertyContractV1]] class TextSearchResponseClassContractV1(TypedDict): - dictionaryUri: str - dictionaryName: str - name: str - referenceCode: str - uri: str - classType: str - description: str - parentClassName: str - relatedIfcEntityNames: list[str] + dictionaryUri: NotRequired[str] + dictionaryName: NotRequired[str] + name: NotRequired[str] + referenceCode: NotRequired[str] + uri: NotRequired[str] + classType: NotRequired[str] + description: NotRequired[str] + parentClassName: NotRequired[str] + relatedIfcEntityNames: NotRequired[list[str]] class TextSearchResponseDictionaryContractV1(TypedDict): - uri: str - name: str + uri: NotRequired[str] + name: NotRequired[str] class TextSearchResponsePropertyContractV1(TypedDict): - dictionaryUri: str - dictionaryName: str - uri: str - name: str - description: str + dictionaryUri: NotRequired[str] + dictionaryName: NotRequired[str] + uri: NotRequired[str] + name: NotRequired[str] + description: NotRequired[str] class ClassContractV1(TypedDict): - dictionaryUri: str + dictionaryUri: NotRequired[str] activationDateUtc: str code: str - creatorLanguageCode: str - countriesOfUse: list[str] - countryOfOrigin: str - deActivationDateUtc: str - definition: str - deprecationExplanation: str - description: str - documentReference: str + creatorLanguageCode: NotRequired[str] + countriesOfUse: NotRequired[list[str]] + countryOfOrigin: NotRequired[str] + deActivationDateUtc: NotRequired[str] + definition: NotRequired[str] + deprecationExplanation: NotRequired[str] + description: NotRequired[str] + documentReference: NotRequired[str] name: str uri: str - replacedObjectCodes: list[str] - replacingObjectCodes: list[str] - revisionDateUtc: str - revisionNumber: int + replacedObjectCodes: NotRequired[list[str]] + replacingObjectCodes: NotRequired[list[str]] + revisionDateUtc: NotRequired[str] + revisionNumber: NotRequired[int] status: str - subdivisionsOfUse: list[str] - uid: str + subdivisionsOfUse: NotRequired[list[str]] + uid: NotRequired[str] versionDateUtc: str - versionNumber: int - visualRepresentationUri: str - classType: str - referenceCode: str - synonyms: list[str] - relatedIfcEntityNames: list[str] - parentClassReference: ClassReferenceContractV1 - classProperties: list[ClassPropertyContractV1] - classRelations: list[ClassRelationContractV1] - childClassReferences: list[ClassReferenceContractV1] + versionNumber: NotRequired[int] + visualRepresentationUri: NotRequired[str] + classType: NotRequired[str] + referenceCode: NotRequired[str] + synonyms: NotRequired[list[str]] + relatedIfcEntityNames: NotRequired[list[str]] + parentClassReference: NotRequired[ClassReferenceContractV1] + classProperties: NotRequired[list[ClassPropertyContractV1]] + classRelations: NotRequired[list[ClassRelationContractV1]] + childClassReferences: NotRequired[list[ClassReferenceContractV1]] + reverseClassRelations: NotRequired[list[ClassReverseRelationContractV1]] + hierarchy: NotRequired[list[HierarchyItemContractV1]] + + +class ClassReverseRelationContractV1(TypedDict): + relationType: str + classUri: str + className: NotRequired[str] + fraction: NotRequired[float] + dictionaryUri: NotRequired[str] + + +class HierarchyItemContractV1(TypedDict): + level: int + name: str + code: str + uri: str class SearchInDictionaryResponseContractV1(TypedDict): - totalCount: int - offset: int - count: int - dictionary: DictionarySearchResultContractV1 + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + dictionary: NotRequired[DictionarySearchResultContractV1] class DictionarySearchResultContractV1(TypedDict): - name: str - uri: str - classes: list[ClassSearchResultContractV1] + name: NotRequired[str] + uri: NotRequired[str] + classes: NotRequired[list[ClassSearchResultContractV1]] class ClassSearchResultContractV1(TypedDict): - name: str - uri: str - referenceCode: str - classType: str - definition: str - synonyms: list[str] + name: NotRequired[str] + uri: NotRequired[str] + referenceCode: NotRequired[str] + classType: NotRequired[str] + definition: NotRequired[str] + synonyms: NotRequired[list[str]] class ClassSearchResponseContractV1(TypedDict): - totalCount: int - offset: int - count: int - classes: list[ClassSearchResponseClassContractV1] + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + classes: NotRequired[list[ClassSearchResponseClassContractV1]] class ClassSearchResponseClassContractV1(TypedDict): - dictionaryUri: str - dictionaryName: str - name: str - referenceCode: str - uri: str - classType: str - description: str - parentClassName: str - relatedIfcEntityNames: list[str] + dictionaryUri: NotRequired[str] + dictionaryName: NotRequired[str] + name: NotRequired[str] + referenceCode: NotRequired[str] + uri: NotRequired[str] + classType: NotRequired[str] + description: NotRequired[str] + parentClassName: NotRequired[str] + relatedIfcEntityNames: NotRequired[list[str]] class CountryContractV1(TypedDict): - code: str - name: str + code: NotRequired[str] + name: NotRequired[str] class LanguageContractV1(TypedDict): @@ -373,16 +403,21 @@ class LanguageContractV1(TypedDict): class ReferenceDocumentContractV1(TypedDict): - title: str - name: str - date: str + title: NotRequired[str] + name: NotRequired[str] + date: NotRequired[str] class UnitContractV1(TypedDict): - code: str - name: str - symbol: str - qudtUri: str + code: NotRequired[str] + name: NotRequired[str] + symbol: NotRequired[str] + qudtUri: NotRequired[str] + + +class CodeNameDto(TypedDict): + code: NotRequired[str] + name: NotRequired[str] class OAuthReceiver(http.server.BaseHTTPRequestHandler): @@ -492,6 +527,10 @@ class Client: # deprecated def ClassificationSearchOpen(self, SearchText, version="v1", DomainNamespaceUris=None, RelatedIfcEntities=None): print(f"function 'Client.ClassificationSearchOpen' is deprecated, use 'Client.search_class' instead") + + if len(SearchText) < 3: + raise ValueError("Search text must be at least 3 characters long.") + if DomainNamespaceUris is None: DomainNamespaceUris = [] if RelatedIfcEntities is None: @@ -686,6 +725,10 @@ class Client: So if result consists of 10 classes and 5 properties, TotalCount will be 15. Classes will be listed first, so if you then use Offset=10 and Limit=5, you will get the 5 properties. """ + + if len(search_text) < 2: + raise ValueError("Search text must be at least 2 characters long.") + if dictionary_uris is None: dictionary_uris = [] endpoint = f"TextSearch/v{version}" @@ -697,7 +740,6 @@ class Client: "limit": limit, } return self.get(endpoint, params) - pass def search_in_dictionary( self, @@ -739,6 +781,9 @@ class Client: this API replaces ClassificationSearch """ + if len(search_text) < 3: + raise ValueError("Search text must be at least 3 characters long.") + if related_ifc_entities is None: related_ifc_entities = [] if dictionary_uris is None: diff --git a/src/bsdd/pyproject.toml b/src/bsdd/pyproject.toml index c648a82d32..0a9df5cb79 100644 --- a/src/bsdd/pyproject.toml +++ b/src/bsdd/pyproject.toml @@ -23,7 +23,10 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", ] -dependencies = ["requests"] +dependencies = [ + "requests", + "typing-extensions", +] [project.optional-dependencies] dev = [ diff --git a/src/bsdd/yml_to_classes.py b/src/bsdd/yml_to_classes.py new file mode 100644 index 0000000000..ba53917051 --- /dev/null +++ b/src/bsdd/yml_to_classes.py @@ -0,0 +1,77 @@ +import yaml +import sys +from pathlib import Path +from typing import Any + + +type_to_python_type = { + "string": "str", + "boolean": "bool", + "array": "list", + "integer": "int", +} + + +def get_python_type(prop_data: dict[str, Any]) -> str: + prop_type = prop_data.get("type") + + if prop_type is None: + ref = format_class(prop_data["$ref"].split("/")[-1]) + return ref + elif prop_type == "number" and prop_data["format"] == "double": + python_type = "float" + else: + python_type = type_to_python_type[prop_type] + + if python_type == "list": + array_type = get_python_type(prop_data["items"]) + return f"{python_type}[{array_type}]" + return python_type + + +def format_class(class_str: str) -> str: + if "." not in class_str: + return class_str + class_name, version = class_str.split(".") + return f"{class_name}{version.upper()}" + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f'Usage: python {Path(__file__).name} "path/to/bSDD OpenAPI.yaml"') + print( + ".yml file available in https://github.com/buildingSMART/bSDD/blob/master/Documentation/bSDD%20OpenAPI.yaml" + ) + exit() + + path = sys.argv[1] + with open(path, "r") as file: + yaml_file = yaml.safe_load(file) + + class_strings: dict[str, str] = {} + for schema_name, schema_data in yaml_file["components"]["schemas"].items(): + props = schema_data.get("properties", {}) + if not props: + continue + class_name = format_class(schema_name) + class_header = f"class {class_name}(TypedDict):" + required = schema_data.get("required", []) + props_str = "" + for prop_name, prop_data in props.items(): + python_type = get_python_type(prop_data) + if prop_name not in required: + python_type = f"NotRequired[{python_type}]" + props_str += f" {prop_name}: {python_type}\n" + class_strings[class_header] = props_str + + # Sort classes according to bsdd.py to reduce git diff noise. + bsdd_contents = Path(__file__).with_name("bsdd.py").read_text() + # Skipping not yet implemented classes for now. + filtered_classes = list(filter(lambda x: x in bsdd_contents, class_strings)) + for class_header in sorted( + filtered_classes, + key=lambda x: bsdd_contents.index(x), + ): + print(class_header) + print(class_strings[class_header]) + print(f"{len(filtered_classes)} classes printed ({len(class_strings)-len(filtered_classes)} skipped).") diff --git a/src/ifc4d/ifc4d/common.py b/src/ifc4d/ifc4d/common.py index 96ffe7a0af..896f604a61 100644 --- a/src/ifc4d/ifc4d/common.py +++ b/src/ifc4d/ifc4d/common.py @@ -1,12 +1,61 @@ -from xerparser.reader import Reader +from __future__ import annotations import ifcopenshell import ifcopenshell.api import ifcopenshell.util.date from datetime import datetime, timedelta, date +from typing import Union, Any, TypedDict, NotRequired + + +class WorkSlot(TypedDict): + DayOfWeek: str + WorkTimes: list[dict[str, Any]] + ifc: Union[ifcopenshell.entity_instance, None] + + +class ExceptionDict(TypedDict): + WorkTime: list[int] + FullDay: list[int] + + +ExceptionsDict = dict[int, dict[int, ExceptionDict]] + + +class Calendar(TypedDict): + Name: str + Type: str + HoursPerDay: int + StandardWorkWeek: list[WorkSlot] + HolidayOrExceptions: NotRequired[ExceptionsDict] + ifc: NotRequired[ifcopenshell.entity_instance] + + +class Activity(TypedDict): + Name: str + Identification: int + StartDate: datetime + FinishDate: datetime + PlannedDuration: float + Status: str + CalendarObjectId: str + ifc: Union[ifcopenshell.entity_instance, None] + + +class WBSEntry(TypedDict): + """Work Breakdown Strcture Entry""" + + Name: str + Code: int + ParentObjectId: Union[int, None] + ifc: Union[ifcopenshell.entity_instance, None] + rel: Union[ifcopenshell.entity_instance, None] + activities: list[int] class ScheduleIfcGenerator: - def __init__(self, file, output, settings): + file: Union[ifcopenshell.file, None] + calendars: dict[int, Calendar] + + def __init__(self, file: Union[ifcopenshell.file, None], output, settings): self.file = file self.work_plan = settings["work_plan"] self.project = settings["project"] @@ -27,7 +76,7 @@ class ScheduleIfcGenerator: "Sunday": 7, } - def create_ifc(self): + def create_ifc(self) -> None: if not self.file: self.file = self.create_boilerplate_ifc() if not self.work_plan: @@ -40,18 +89,19 @@ class ScheduleIfcGenerator: if self.output: self.file.write(self.output) - def create_work_schedule(self): + def create_work_schedule(self) -> ifcopenshell.entity_instance: return ifcopenshell.api.run( "sequence.add_work_schedule", self.file, name=self.project["Name"], work_plan=self.work_plan ) - def create_calendars(self): - for calendar in self.calendars.values(): + def create_calendars(self) -> None: + for calendar_id, calendar in self.calendars.items(): calendar["ifc"] = ifcopenshell.api.run("sequence.add_work_calendar", self.file, name=calendar["Name"]) + calendar["ifc"].Identification = str(calendar_id) self.process_working_week(calendar["StandardWorkWeek"], calendar["ifc"]) self.process_exceptions(calendar.get("HolidayOrExceptions"), calendar["ifc"]) - def process_working_week(self, week, calendar): + def process_working_week(self, week: list[WorkSlot], calendar: ifcopenshell.entity_instance) -> None: for day in week: if day["ifc"] or not day.get("WorkTimes"): continue @@ -94,7 +144,9 @@ class ScheduleIfcGenerator: end_time=work_time["Finish"], ) - def process_exceptions(self, exceptions, calendar): + def process_exceptions( + self, exceptions: Union[ExceptionsDict, None], calendar: ifcopenshell.entity_instance + ) -> None: if exceptions: for year, year_data in exceptions.items(): for month, month_data in year_data.items(): @@ -103,7 +155,9 @@ class ScheduleIfcGenerator: if month_data["WorkTime"]: self.process_work_time_exceptions(year, month, month_data, calendar) - def process_full_day_exceptions(self, year, month, month_data, calendar): + def process_full_day_exceptions( + self, year: int, month: int, month_data: dict[str, Any], calendar: ifcopenshell.entity_instance + ): work_time = ifcopenshell.api.run( "sequence.add_work_time", self.file, work_calendar=calendar, time_type="ExceptionTimes" ) @@ -130,7 +184,9 @@ class ScheduleIfcGenerator: attributes={"DayComponent": month_data["FullDay"], "MonthComponent": [month]}, ) - def process_work_time_exceptions(self, year, month, month_data, calendar): + def process_work_time_exceptions( + self, year: int, month: int, month_data: dict[str, Any], calendar: ifcopenshell.entity_instance + ) -> None: for day in month_data["WorkTime"]: if day["ifc"]: continue @@ -179,13 +235,13 @@ class ScheduleIfcGenerator: end_time=work_time["Finish"], ) - def create_tasks(self, work_schedule): + def create_tasks(self, work_schedule: ifcopenshell.entity_instance) -> None: for wbs in self.wbs.values(): self.create_task_from_wbs(wbs, work_schedule) for activity_id in self.root_activites: self.create_task_from_activity(self.activities[activity_id], None, work_schedule) - def create_task_from_wbs(self, wbs, work_schedule): + def create_task_from_wbs(self, wbs: WBSEntry, work_schedule: ifcopenshell.entity_instance) -> None: if not self.wbs.get(wbs["ParentObjectId"]): wbs["ParentObjectId"] = None wbs["ifc"] = ifcopenshell.api.run( @@ -207,7 +263,12 @@ class ScheduleIfcGenerator: for activity_id in wbs["activities"]: self.create_task_from_activity(self.activities[activity_id], wbs, None) - def create_task_from_activity(self, activity, wbs, work_schedule): + def create_task_from_activity( + self, + activity: Activity, + wbs: Union[WBSEntry, None], + work_schedule: Union[ifcopenshell.entity_instance, None], + ) -> None: activity["ifc"] = ifcopenshell.api.run( "sequence.add_task", self.file, @@ -228,7 +289,6 @@ class ScheduleIfcGenerator: ) task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=activity["ifc"]) calendar = self.calendars[activity["CalendarObjectId"]] - # print(calendar, self.calendars) # Seems intermittently crashy - can we investigate for larger files? ifcopenshell.api.run( "control.assign_control", @@ -254,7 +314,7 @@ class ScheduleIfcGenerator: }, ) - def create_rel_sequences(self): + def create_rel_sequences(self) -> None: self.sequence_type_map = { "Start to Start": "START_START", "Start to Finish": "START_FINISH", @@ -285,8 +345,7 @@ class ScheduleIfcGenerator: duration_type="WORKTIME", ) - def create_resources(self): - # print("Resources", self.resources) + def create_resources(self) -> None: if self.resources: for id, resource in self.resources.items(): @@ -312,8 +371,7 @@ class ScheduleIfcGenerator: resource["ifc"] = ifcopenshell.api.run( "resource.add_resource", self.file, **{"ifc_class": "IfcCrewResource", "name": resource["Name"]} ) - print(self.resources) - def create_boilerplate_ifc(self): + def create_boilerplate_ifc(self) -> None: self.file = ifcopenshell.file(schema="IFC4") self.work_plan = self.file.create_entity("IfcWorkPlan") diff --git a/src/ifc4d/ifc4d/pp2ifc.py b/src/ifc4d/ifc4d/pp2ifc.py index b0faddc22d..5ce9b68b48 100644 --- a/src/ifc4d/ifc4d/pp2ifc.py +++ b/src/ifc4d/ifc4d/pp2ifc.py @@ -6,15 +6,14 @@ print("In module products sys.path[0], __package__ ==", sys.path[0], __package__ sys.path.append(sys.path[0]) import sqlite3 -import math import datetime from datetime import timedelta -import ifcopenshell -import ifcopenshell.api import ifcopenshell.util.date from .wpattern import AstaCalendarWorkPattern -from .common import ScheduleIfcGenerator +from .common import ScheduleIfcGenerator, Calendar, WBSEntry, Activity import time +from collections import defaultdict +from typing import Any list_of_tables = [ @@ -29,6 +28,9 @@ list_of_tables = [ class PP2Ifc: + wbs: dict[int, WBSEntry] + activities: dict[int, Activity] + def __init__(self): self.pp = None @@ -45,17 +47,17 @@ class PP2Ifc: self.relationship_map = {0: "FINISH_START", 1: "FINISH_FINISH", 2: "START_START", 3: "START_FINISH"} - def get_json(self, table_name): + def get_json(self, table_name: str) -> list[dict[str, Any]]: self.cur.execute("select * from " + table_name) r = [dict((self.cur.description[i][0], value) for i, value in enumerate(row)) for row in self.cur.fetchall()] return r - def get_json_with_filter(self, table_name, attr_name, attr_value): + def get_json_with_filter(self, table_name: str, attr_name: str, attr_value: Any) -> list[dict[str, Any]]: self.cur.execute("select * from " + table_name + " where " + attr_name + " = " + str(attr_value)) r = [dict((self.cur.description[i][0], value) for i, value in enumerate(row)) for row in self.cur.fetchall()] return r - def execute(self): + def execute(self) -> None: self.con = sqlite3.connect(self.pp) self.cur = self.con.cursor() self.parse_pp() @@ -78,17 +80,15 @@ class PP2Ifc: print("Parsing time is", end - start) print("IFC Creation took", end2 - end) print("Overall Time", end2 - start) - # self.create_ifc() - - def parse_pp(self): + def parse_pp(self) -> None: project = self.get_json("PROJECT_SUMMARY")[0] self.project["Name"] = project["SHORT_NAME"] self.parse_calendar_pp() self.parse_bar() - self.parse_relationship_pp(project) + self.parse_relationship_pp() - def parse_calendar_pp(self): + def parse_calendar_pp(self) -> None: calendars = self.get_json("CALENDAR") wp_data = self.get_json("WORK_PATTERN") work_types = self.get_json("EXCEPTIONN") @@ -111,40 +111,37 @@ class PP2Ifc: ) timex.append(work_times.total_seconds() / (60 * 60)) - self.calendars[calendar_id] = { - "Name": calendar["NAME"], - "Type": "NOTDEFINED", - "HoursPerDay": max(timex), - "StandardWorkWeek": wp.dict_wp, - "HolidayOrExceptions": exceptions, - } - # print(self.calendars[calendar_id]) + self.calendars[calendar_id] = Calendar( + Name=calendar["NAME"], + Type="NOTDEFINED", + HoursPerDay=max(timex), + StandardWorkWeek=wp.dict_wp, + HolidayOrExceptions=exceptions, + ) - def parse_bar(self): + def parse_bar(self) -> None: bars = self.get_json("BAR") expanded_tasks = {t["BAR"]: t for t in self.get_json("EXPANDED_TASK")} tasks = {t["BAR"]: t for t in self.get_json("TASK")} milestones = {m["BAR"]: m for m in self.get_json("MILESTONE")} + expanded_tasks_to_bars: dict[int, int] = {v["ID"]: k for k, v in expanded_tasks.items()} - schedule_task = None - - bar_tasks = {} - wbs_activities = {} + wbs_activities: defaultdict[int, list[int]] = defaultdict(list) for bar in bars: extra_type = None extra_data = None - bar_id = None + bar_id: int = bar["ID"] - if bar["ID"] in expanded_tasks: + if bar_id in expanded_tasks: extra_type = "EXPANDED_TASK" - extra_data = expanded_tasks[bar["ID"]] - elif bar["ID"] in tasks: + extra_data = expanded_tasks[bar_id] + elif bar_id in tasks: extra_type = "TASK" - extra_data = tasks[bar["ID"]] - elif bar["ID"] in milestones: + extra_data = tasks[bar_id] + elif bar_id in milestones: extra_type = "MILESTONE" - extra_data = milestones[bar["ID"]] + extra_data = milestones[bar_id] else: # Not sure where this might be the case continue @@ -152,13 +149,13 @@ class PP2Ifc: name = bar["NAME"] if extra_data["NAME"]: name += f" - {extra_data['NAME']}" - bar_tasks[extra_data["ID"]] = {"bar": bar, "extra_type": extra_type, "extra_data": extra_data} + parent_task_id = expanded_tasks_to_bars[bar["EXPANDED_TASK"]] if bar["EXPANDED_TASK"] > 0 else None if extra_type == "EXPANDED_TASK": - self.wbs[extra_data["ID"]] = { + self.wbs[bar_id] = { "Name": name, - "Code": extra_data["ID"], - "ParentObjectId": bar["EXPANDED_TASK"] if bar["EXPANDED_TASK"] > 0 else None, + "Code": bar_id, + "ParentObjectId": parent_task_id, "ifc": None, "rel": None, "activities": [], @@ -175,24 +172,24 @@ class PP2Ifc: else: activity_duration = 0.0 - self.activities[extra_data["ID"]] = { - "Name": name, - "Identification": extra_data["ID"], - "StartDate": datetime.datetime.fromisoformat(extra_data["LINKABLE_START"]), - "FinishDate": datetime.datetime.fromisoformat(extra_data["LINKABLE_FINISH"]), - "PlannedDuration": activity_duration, - "Status": "PLANNED", - "CalendarObjectId": extra_data["CALENDAR"], - "ifc": None, - } - wbs_activities.setdefault(bar["EXPANDED_TASK"], []).append(extra_data["ID"]) + self.activities[bar_id] = Activity( + Name=name, + Identification=bar_id, + StartDate=datetime.datetime.fromisoformat(extra_data["LINKABLE_START"]), + FinishDate=datetime.datetime.fromisoformat(extra_data["LINKABLE_FINISH"]), + PlannedDuration=activity_duration, + Status="PLANNED", + CalendarObjectId=extra_data["CALENDAR"], + ifc=None, + ) + # No idea if it's possible that activity won't have a parent task. + wbs_activities[parent_task_id].append(bar_id) for wbs, activities in wbs_activities.items(): self.wbs[wbs]["activities"] = activities - def parse_relationship_pp(self, project): + def parse_relationship_pp(self) -> None: relations = self.get_json("LINK") - # print(relations) for relationship in relations: predecessor = relationship["START_TASK"] successor = relationship["END_TASK"] diff --git a/src/ifc4d/ifc4d/wpattern.py b/src/ifc4d/ifc4d/wpattern.py index 304f03777a..72a150aea9 100644 --- a/src/ifc4d/ifc4d/wpattern.py +++ b/src/ifc4d/ifc4d/wpattern.py @@ -8,10 +8,12 @@ for each day as a key there is a list of working times with the format import re from datetime import time, datetime +from typing import Any +from ifc4d.common import WorkSlot class AstaCalendarWorkPattern: - def get_keys(self, s): + def get_keys(self, s: str) -> list[str]: regex = r"\<(\".+?\")\>(\w|\d|)+?" matches = re.finditer(regex, s) @@ -20,30 +22,115 @@ class AstaCalendarWorkPattern: matcs.append(match.group(1)) return matcs - def get_values(self, s): - rx2 = r"<\"[^<>]+\">" + def get_values(self, s: str) -> list[str]: + rx2 = r"<\"[^<>]+\">" # TODO: unused? data = re.split("<[^<>]+>", s) return data - def __init__(self, string, work_type_ids): - self.string = string - self.Days = { - "en": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], - "sv": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"], - "de": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"], - } - self.keys = self.get_keys(string) + # Used the list of Blender supported languages as a reference. + # Method to generate names for other language: + # def get_day_names(lang): + # from babel.dates import get_day_names + # from babel import Locale + # locale = Locale(lang) + # return [i.capitalize() for i in get_day_names(locale=locale, width="wide").values()] + Days = { + "am": ("እሑድ", "ሰኞ", "ማክሰኞ", "ረቡዕ", "ሐሙስ", "ዓርብ", "ቅዳሜ"), + "ar": ("الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"), + "be": ("Нядзеля", "Панядзелак", "Аўторак", "Серада", "Чацвер", "Пятніца", "Субота"), + "bg": ("Неделя", "Понеделник", "Вторник", "Сряда", "Четвъртък", "Петък", "Събота"), + "ca": ("Diumenge", "Dilluns", "Dimarts", "Dimecres", "Dijous", "Divendres", "Dissabte"), + "cs": ("Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"), + "da": ("Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"), + "de": ("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"), + "el": ("Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"), + "en": ("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"), + "eo": ("Dimanĉo", "Lundo", "Mardo", "Merkredo", "Ĵaŭdo", "Vendredo", "Sabato"), + "es": ("Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"), + "et": ("Pühapäev", "Esmaspäev", "Teisipäev", "Kolmapäev", "Neljapäev", "Reede", "Laupäev"), + "eu": ("Igandea", "Astelehena", "Asteartea", "Asteazkena", "Osteguna", "Ostirala", "Larunbata"), + "fa": ("یکشنبه", "دوشنبه", "سه\u200cشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"), + "fi": ("Sunnuntaina", "Maanantaina", "Tiistaina", "Keskiviikkona", "Torstaina", "Perjantaina", "Lauantaina"), + "fr": ("Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"), + "ha": ("Lahadi", "Litinin", "Talata", "Laraba", "Alhamis", "Jummaʼa", "Asabar"), + "he": ("יום ראשון", "יום שני", "יום שלישי", "יום רביעי", "יום חמישי", "יום שישי", "יום שבת"), + "hi": ("रविवार", "सोमवार", "मंगलवार", "बुधवार", "गुरुवार", "शुक्रवार", "शनिवार"), + "hr": ("Nedjelja", "Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak", "Subota"), + "hu": ("Vasárnap", "Hétfő", "Kedd", "Szerda", "Csütörtök", "Péntek", "Szombat"), + "id": ("Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"), + "it": ("Domenica", "Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato"), + "ja": ("日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"), + "ka": ("კვირა", "ორშაბათი", "სამშაბათი", "ოთხშაბათი", "ხუთშაბათი", "პარასკევი", "შაბათი"), + "kk": ("Жексенбі", "Дүйсенбі", "Сейсенбі", "Сәрсенбі", "Бейсенбі", "Жұма", "Сенбі"), + "km": ("អាទិត្យ", "ច័ន្ទ", "អង្គារ", "ពុធ", "ព្រហស្បតិ៍", "សុក្រ", "សៅរ៍"), + "ko": ("일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"), + "ky": ("Жекшемби", "Дүйшөмбү", "Шейшемби", "Шаршемби", "Бейшемби", "Жума", "Ишемби"), + "ne": ("आइतबार", "सोमबार", "मङ्गलबार", "बुधबार", "बिहिबार", "शुक्रबार", "शनिबार"), + "nl": ("Zondag", "Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag"), + "pl": ("Niedziela", "Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek", "Sobota"), + "pt": ("Domingo", "Segunda-feira", "Terça-feira", "Quarta-feira", "Quinta-feira", "Sexta-feira", "Sábado"), + "ro": ("Duminică", "Luni", "Marți", "Miercuri", "Joi", "Vineri", "Sâmbătă"), + "ru": ("Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"), + "sk": ("Nedeľa", "Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota"), + "sl": ("Nedelja", "Ponedeljek", "Torek", "Sreda", "Četrtek", "Petek", "Sobota"), + "sr": ("Недеља", "Понедељак", "Уторак", "Среда", "Четвртак", "Петак", "Субота"), + "sv": ("Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"), + "sw": ("Jumapili", "Jumatatu", "Jumanne", "Jumatano", "Alhamisi", "Ijumaa", "Jumamosi"), + "ta": ("ஞாயிறு", "திங்கள்", "செவ்வாய்", "புதன்", "வியாழன்", "வெள்ளி", "சனி"), + "th": ("วันอาทิตย์", "วันจันทร์", "วันอังคาร", "วันพุธ", "วันพฤหัสบดี", "วันศุกร์", "วันเสาร์"), + "tr": ("Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"), + "uk": ("Неділя", "Понеділок", "Вівторок", "Середа", "Четвер", "Пʼятниця", "Субота"), + "ur": ("اتوار", "پیر", "منگل", "بدھ", "جمعرات", "جمعہ", "ہفتہ"), + "vi": ("Chủ nhật", "Thứ hai", "Thứ ba", "Thứ tư", "Thứ năm", "Thứ sáu", "Thứ bảy"), + } + + def get_day_names(self, string: str) -> None: + """Parse day names from string and translate them to English. + + :raises Exception: If language is not yet supported. + """ + # Parse day names. + day_names = [day_name.strip('"') for day_name in self.get_keys(string)] + # Identify language. + day_names_set = set(day_names) + for lang in self.Days: + lang_day_names = self.Days[lang] + if day_names_set.issubset(lang_day_names): + en_day_names = self.Days["en"] + self.lang = lang + # Translate day names to English. + self.day_names = [en_day_names[lang_day_names.index(d)] for d in day_names] + return + + msg = ( + "Could not identify language for work patterns, need to update Days dictionary." + "Please report it to IfcOpenShell Github issues" + f"\nDay names provided: {day_names}." + "\nAvailable languages:" + ) + for lang in self.Days: + msg += f"\n - {lang}: {self.Days[lang]}" + raise Exception(msg) + + lang: str + day_names: list[str] + + def __init__(self, string: str, work_type_ids: list[int]): + self.get_day_names(string) self.values = self.get_values(string) - self.dict_wp = [] - for d, m in zip(self.values[1:], self.keys): - splt_data = d.strip().split(",") - workhours = [] + self.dict_wp: list[WorkSlot] = [] + + for value, day_name in zip(self.values[1:], self.day_names, strict=True): + splt_data = value.strip().split(",") + workhours: list[dict[str, Any]] = [] if len(splt_data) >= 7: number_of_work_slots = splt_data[1] for i in range(int(number_of_work_slots)): - if int(splt_data[2 + i * 3]) in work_type_ids: - st1_1 = datetime.strptime(splt_data[2 + i * 3 + 1].ljust(5, "0"), "%H%M%S") - st1_2 = datetime.strptime(splt_data[2 + i * 3 + 2].ljust(5, "0"), "%H%M%S") + work_slot_data = splt_data[2 + i * 3 :][:3] + work_type_id, start_time, end_time = work_slot_data + if int(work_type_id) in work_type_ids: + st1_1 = datetime.strptime(start_time.ljust(5, "0"), "%H%M%S") + st1_2 = datetime.strptime(end_time.ljust(5, "0"), "%H%M%S") st = { "Start": time(st1_1.hour, st1_1.minute), "Finish": time(st1_2.hour, st1_2.minute), @@ -51,21 +138,9 @@ class AstaCalendarWorkPattern: } workhours.append(st) - self.dict_wp.append({"DayOfWeek": m.replace('"', ""), "WorkTimes": workhours, "ifc": None}) + self.dict_wp.append(WorkSlot(DayOfWeek=day_name, WorkTimes=workhours, ifc=None)) - # Translate day names to english - def translate_days(days, wp): - for index, day in enumerate(days["en"]): - for lang in days.keys(): - if lang == "en": - continue - if wp["DayOfWeek"] == days[lang][index]: - wp["DayOfWeek"] = days["en"][index] - return - - translate_days(self.Days, self.dict_wp[-1]) - - for day in self.Days["en"]: - if not len(list(filter(lambda d: d["DayOfWeek"] == day, self.dict_wp))) > 0: - print("MISSING", day) - self.dict_wp.append({"DayOfWeek": day, "WorkTimes": [], "ifc": None}) + # Add empty work times for missing week days. + missing_week_days = set(self.Days[self.lang]) - set(self.day_names) + for day_name in missing_week_days: + self.dict_wp.append(WorkSlot(DayOfWeek=day_name, WorkTimes=[], ifc=None)) diff --git a/src/ifc5d/ifc5d/ifc2json.py b/src/ifc5d/ifc5d/ifc2json.py new file mode 100644 index 0000000000..fae04126af --- /dev/null +++ b/src/ifc5d/ifc5d/ifc2json.py @@ -0,0 +1,179 @@ +import ifcopenshell +import ifcopenshell.util.unit +from typing import Any +import ifcopenshell.util.cost +import ifcopenshell.util.date +from ifcopenshell.util.classification import get_references + + +CostItem = dict[str, Any] + + +class ifc5D2json: + def __init__(self): + self.json: str = None + self.file_path: str = None + self.file: ifcopenshell.file = None + self.cost_schedule: ifcopenshell.entity_instance = None + self.units: dict[str, ifcopenshell.entity_instance] = {} + self._cost_values = {} + self.data: list[dict[str, Any]] = [] + + def convert_ifc_to_json(self): + self.data = [] + if not isinstance(self.file_path, str): + self.file = self.file_path + else: + self.file = ifcopenshell.open(self.file_path) + if not self.cost_schedule: + self.extract_cost_schedules() + else: + self.extract_cost_schedule() + return self.data + + def extract_cost_schedule(self): + data = self.cost_schedule.get_info(recursive=True) + data["cost_items"] = self.build_schedule_tree(self.cost_schedule) + self.data.append(data) + + def extract_cost_schedules(self): + for cost_schedule in self.file.by_type("IfcCostSchedule"): + data = cost_schedule.get_info(recursive=True) + data["cost_items"] = self.build_schedule_tree(cost_schedule) + self.data.append(data) + + def build_schedule_tree(self, cost_schedule: ifcopenshell.entity_instance) -> dict[str, Any]: + schedule_data = [] + self._cost_values = {} + for rel in cost_schedule.Controls or []: + for cost_item in rel.RelatedObjects: + self.extract_cost_item_data(cost_item, schedule_data) + return schedule_data + + def extract_cost_item_data(self, cost_item: ifcopenshell.entity_instance, json_data): + data = {} + self.extract_cost_item_quantities(cost_item, data) + self.populate_cost_values(cost_item, data) + self.populate_nesting_index(cost_item, data) + cost_rate = ifcopenshell.util.cost.get_cost_rate(self.file, cost_item) + data = {**data, **cost_item.get_info(recursive=True)} + data["id"] = cost_item.id() + data["IsNestedBy"] = [] + data["IsSum"] = self.check_if_cost_item_is_sum(cost_item) + data["Classification"] = self.get_cost_classifications(cost_item) + data["CostRate"] = { + "id": cost_rate.id() if cost_rate else None, + "Identification": cost_rate.Identification if cost_rate else None, + "Name": cost_rate.Name if cost_rate else None, + } + json_data.append(data) + for rel in cost_item.IsNestedBy or []: + for sub_cost in rel.RelatedObjects: + self.extract_cost_item_data(sub_cost, data["IsNestedBy"]) + + def get_cost_classifications(self, cost_item: ifcopenshell.entity_instance) -> list: + results = [] + if cost_item: + for reference in get_references(cost_item): + data = reference.get_info() + del data["ReferencedSource"] + results.append(data) + return results + + def check_if_cost_item_is_sum(self, cost_item: ifcopenshell.entity_instance) -> bool: + cost_values = [] + if cost_item.is_a("IfcCostItem"): + cost_values = cost_item.CostValues + elif cost_item.is_a("IfcCostValue"): + cost_values = cost_item.Components + for cost_value in cost_values or []: + if cost_value.Category == "*": + return True + return False + + def populate_nesting_index(self, cost_item, data): + data["NestingIndex"] = None + for rel in cost_item.Nests or []: + data["NestingIndex"] = rel.RelatedObjects.index(cost_item) + + def populate_cost_values(self, root_element, data): + # data["CostValues"] = [] + data["CategoryValues"] = {} + data["UnitBasisValueComponent"] = None + data["UnitBasisUnitSymbol"] = None + data["TotalAppliedValue"] = 0.0 + data["TotalCost"] = 0.0 + has_unit_basis = False + is_sum = False + if root_element.is_a("IfcCostItem"): + values = root_element.CostValues + elif root_element.is_a("IfcConstructionResource"): + values = root_element.BaseCosts + for cost_value in values or []: + self.extract_cost_value(root_element, data, cost_value) + # data["CostValues"].append(cost_value.id()) + data["TotalAppliedValue"] += self._cost_values[cost_value.id()]["AppliedValue"] + if cost_value.UnitBasis: + cost_value_data = self._cost_values[cost_value.id()] + data["UnitBasisValueComponent"] = cost_value_data["UnitBasis"]["ValueComponent"] + data["UnitBasisUnitSymbol"] = cost_value_data["UnitBasis"]["UnitSymbol"] + has_unit_basis = True + else: + data["UnitBasisValueComponent"] = 1 + data["UnitBasisUnitSymbol"] = "U" + if cost_value.Category == "*": + is_sum = True + cost_quantity = 1 if data["TotalCostQuantity"] is None else data["TotalCostQuantity"] + if has_unit_basis: + data["TotalCost"] = data["TotalAppliedValue"] * cost_quantity / data["UnitBasisValueComponent"] + else: + data["TotalCost"] = data["TotalAppliedValue"] * cost_quantity + if is_sum: + data["TotalAppliedValue"] = None + + def extract_cost_value(self, root_element, root_element_data, cost_value): + value_data = cost_value.get_info() + del value_data["AppliedValue"] + if value_data["UnitBasis"]: + data = cost_value.UnitBasis.get_info() + data["ValueComponent"] = data["ValueComponent"].wrappedValue + data["UnitComponent"] = data["UnitComponent"].id() + data["UnitSymbol"] = ifcopenshell.util.unit.get_unit_symbol(cost_value.UnitBasis.UnitComponent) + value_data["UnitBasis"] = data + if value_data["ApplicableDate"]: + value_data["ApplicableDate"] = ifcopenshell.util.date.ifc2datetime(value_data["ApplicableDate"]) + if value_data["FixedUntilDate"]: + value_data["FixedUntilDate"] = ifcopenshell.util.date.ifc2datetime(value_data["FixedUntilDate"]) + value_data["Components"] = [c.id() for c in value_data["Components"] or []] + value_data["AppliedValue"] = ifcopenshell.util.cost.calculate_applied_value(root_element, cost_value) + + if cost_value.Category not in [None, "*"]: + root_element_data["CategoryValues"].setdefault(cost_value.Category, 0) + root_element_data["CategoryValues"][cost_value.Category] += value_data["AppliedValue"] + + value_data["Formula"] = ifcopenshell.util.cost.serialise_cost_value(cost_value) + + self._cost_values[cost_value.id()] = value_data + for component in cost_value.Components or []: + self.extract_cost_value(root_element, root_element_data, component) + + def extract_cost_item_quantities(self, cost_item, data): + data["TotalCostQuantity"] = ifcopenshell.util.cost.get_total_quantity(cost_item) + data["UnitSymbol"] = "-" + if cost_item.CostQuantities: + quantity = cost_item.CostQuantities[0] + data["QuantityType"] = quantity.is_a() + unit = ifcopenshell.util.unit.get_property_unit(quantity, self.file) + if unit: + data["UnitSymbol"] = ifcopenshell.util.unit.get_unit_symbol(unit) + if quantity.is_a("IfcPhysicalSimpleQuantity"): + measure_class = ( + quantity.wrapped_data.declaration() + .as_entity() + .attribute_by_index(3) + .type_of_attribute() + .declared_type() + .name() + ) + if "Count" in measure_class: + data["UnitSymbol"] = "U" diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index f73d5870c1..6673997ce2 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -24,22 +24,29 @@ import ifcopenshell.api.pset import ifcopenshell.util.unit import ifcopenshell.util.element import ifcopenshell.util.selector +import ifcopenshell.util.shape import ifcopenshell.util.representation import multiprocessing from collections import namedtuple -from typing import Any +from typing import Any, Literal, get_args Function = namedtuple("Function", ["measure", "name", "description"]) -rules = {} +RULE_SET = Literal["IFC4QtoBaseQuantities", "IFC4QtoBaseQuantitiesBlender"] +rules: dict[RULE_SET, dict[str, Any]] = {} cwd = os.path.dirname(os.path.realpath(__file__)) -for name in ("IFC4QtoBaseQuantities", "IFC4QtoBaseQuantitiesBlender"): +for name in get_args(RULE_SET): with open(os.path.join(cwd, name + ".json"), "r") as f: rules[name] = json.load(f) def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], rules: dict) -> dict: + """ + + :param rules: Set of rules from `ifc5d.qto.rules`. + + """ results = {} for calculator, queries in rules["calculators"].items(): calculator = calculators[calculator] @@ -51,6 +58,11 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst def edit_qtos(ifc_file: ifcopenshell.file, results: dict[ifcopenshell.entity_instance, Any]) -> None: + """ + + :param results: Results from `ifc5d.qto.quantify`. + + """ for element, qtos in results.items(): for name, quantities in qtos.items(): qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False) diff --git a/src/ifcconvert/IfcConvert.cpp b/src/ifcconvert/IfcConvert.cpp index d1ce1dceeb..fac54d6025 100644 --- a/src/ifcconvert/IfcConvert.cpp +++ b/src/ifcconvert/IfcConvert.cpp @@ -297,10 +297,6 @@ 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 " diff --git a/src/ifcdiff/ifcdiff.py b/src/ifcdiff/ifcdiff.py index a4f654623a..889f23ca37 100755 --- a/src/ifcdiff/ifcdiff.py +++ b/src/ifcdiff/ifcdiff.py @@ -34,7 +34,7 @@ import ifcopenshell.util.placement import ifcopenshell.util.classification import ifcopenshell.util.representation from deepdiff import DeepDiff -from ordered_set import OrderedSet +from orderly_set import OrderedSet from typing import Optional, Union, Literal, Any @@ -231,9 +231,9 @@ class IfcDiff: def get_settings(self, ifc: ifcopenshell.file) -> ifcopenshell.geom.settings: settings = ifcopenshell.geom.settings() # Are you feeling lucky? - settings.set(settings.DISABLE_BOOLEAN_RESULT, True) + settings.set("disable-boolean-result", True) # Are you feeling very lucky? - settings.set(settings.DISABLE_OPENING_SUBTRACTIONS, True) + settings.set("disable-opening-subtractions", True) # Facetation is to accommodate broken Revit files # See https://forums.buildingsmart.org/t/suggestions-on-how-to-improve-clarity-of-representation-context-usage-in-documentation/3663/6?u=moult body_contexts = [ diff --git a/src/ifcgeom/AbstractKernel.cpp b/src/ifcgeom/AbstractKernel.cpp index a82e7c1641..f785114420 100644 --- a/src/ifcgeom/AbstractKernel.cpp +++ b/src/ifcgeom/AbstractKernel.cpp @@ -3,6 +3,7 @@ #include "../ifcgeom/IfcGeomElement.h" #include "../ifcgeom/ConversionSettings.h" #include "../ifcgeom/abstract_mapping.h" +#include "../ifcgeom/piecewise_function_evaluator.h" #ifdef IFOPSH_WITH_OPENCASCADE #include "../ifcgeom/kernels/opencascade/OpenCascadeKernel.h" @@ -29,8 +30,15 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert(const taxonomy::pt try { return dispatch_conversion<0>::dispatch(this, item->kind(), item, results); } catch (std::exception& e) { - Logger::Error(e, item->instance); - return false; + std::string prev_exception = std::string(e.what()); + try { + return dispatch_with_upgrade<0>::dispatch(this, item, results); + } catch (std::exception& e) { + Logger::Error(prev_exception + " Conversion for upgraded element failed with: " + std::string(e.what()), item->instance); + return false; + } catch (...) { + return false; + } } catch (...) { // @todo we can't log OCCT exceptions here, can we do some reraising to solve this? return false; @@ -220,7 +228,8 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert_impl(const taxonom } bool ifcopenshell::geometry::kernels::AbstractKernel::convert_impl(const taxonomy::piecewise_function::ptr item, IfcGeom::ConversionResults& cs) { - auto expl = item->evaluate(); + piecewise_function_evaluator evaluator(item); + auto expl = evaluator.evaluate(); expl->instance = item->instance; return convert(expl, cs); } diff --git a/src/ifcgeom/AbstractKernel.h b/src/ifcgeom/AbstractKernel.h index 87a6e4d623..d06fe6bf4a 100644 --- a/src/ifcgeom/AbstractKernel.h +++ b/src/ifcgeom/AbstractKernel.h @@ -86,7 +86,7 @@ namespace { struct dispatch_conversion { static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, ifcopenshell::geometry::taxonomy::kinds item_kind, const ifcopenshell::geometry::taxonomy::ptr item, IfcGeom::ConversionResults& results) { if (N == item_kind) { - auto concrete_item = ifcopenshell::geometry::taxonomy::template cast>(item); + auto concrete_item = std::static_pointer_cast>(item); return kernel->convert_impl(concrete_item, results); } else { return dispatch_conversion::dispatch(kernel, item_kind, item, results); @@ -102,6 +102,26 @@ namespace { } }; + template + struct dispatch_with_upgrade { + static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, const ifcopenshell::geometry::taxonomy::ptr item, IfcGeom::ConversionResults& results) { + auto concrete_item = ifcopenshell::geometry::taxonomy::template dcast>(item); + if (concrete_item) { + return kernel->convert_impl(concrete_item, results); + } else { + return dispatch_with_upgrade::dispatch(kernel, item, results); + } + } + }; + + template <> + struct dispatch_with_upgrade { + static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel*, const ifcopenshell::geometry::taxonomy::ptr item, IfcGeom::ConversionResults&) { + Logger::Error("No conversion with upgrade for " + std::to_string(item->kind())); + return false; + } + }; + template struct TupleTypeIndex; diff --git a/src/ifcgeom/ConversionSettings.cpp b/src/ifcgeom/ConversionSettings.cpp index 3841191f80..48bdff2643 100644 --- a/src/ifcgeom/ConversionSettings.cpp +++ b/src/ifcgeom/ConversionSettings.cpp @@ -1,37 +1,34 @@ #include "ConversionSettings.h" -/* -void ifcopenshell::geometry::ConversionSettings::setValue(GeomValue var, double value) { - values_[var] = value; -} - -double ifcopenshell::geometry::ConversionSettings::getValue(GeomValue var) const { - return values_[var]; -} -*/ - template -void istream_helper(std::istream& in, std::set& ints) { +void istream_helper(std::istream& in, T& vs) { std::string tokens; in >> tokens; std::vector strs; boost::split(strs, tokens, boost::is_any_of(",")); for (auto& s : strs) { - if constexpr (std::is_same_v) { - ints.insert(s); - } else { - ints.insert(boost::lexical_cast(s)); + if constexpr (std::is_same_v, std::set>) { + vs.insert(s); + } else if constexpr (std::is_same_v, std::set>) { + vs.insert(boost::lexical_cast(s)); + } else if constexpr (std::is_same_v, std::vector>) { + vs.push_back(boost::lexical_cast(s)); } } } std::istream& std::operator>>(istream& in, set& ints) { - istream_helper(in, ints); + istream_helper>(in, ints); return in; } std::istream& std::operator>>(istream& in, set& strs) { - istream_helper(in, strs); + istream_helper>(in, strs); + return in; +} + +std::istream& std::operator>>(istream& in, vector& ds) { + istream_helper>(in, ds); return in; } @@ -81,3 +78,19 @@ std::istream& ifcopenshell::geometry::settings::operator>>(std::istream& in, Out } return in; } + +std::istream& ifcopenshell::geometry::settings::operator>>(std::istream& in, TriangulationMethod& v) { + std::string token; + in >> token; + boost::to_upper(token); + if (token == "TRIANGLE_MESH") { + v = TRIANGLE_MESH; + } else if (token == "POLYHEDRON_WITHOUT_HOLES") { + v = POLYHEDRON_WITHOUT_HOLES; + } else if (token == "POLYHEDRON_WITH_HOLES") { + v = POLYHEDRON_WITH_HOLES; + } else { + in.setstate(std::ios_base::failbit); + } + return in; +} diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h index d0e5f2bc6b..5a622daa98 100644 --- a/src/ifcgeom/ConversionSettings.h +++ b/src/ifcgeom/ConversionSettings.h @@ -24,6 +24,7 @@ namespace po = boost::program_options; namespace std { istream& operator>>(istream& in, set& ints); istream& operator>>(istream& in, set& ints); + istream& operator>>(istream& in, vector& vs); } #endif @@ -43,7 +44,10 @@ namespace ifcopenshell { struct SettingBase { typedef T base_type; - boost::optional value; + // 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; SettingBase() {} @@ -59,24 +63,34 @@ namespace ifcopenshell { // @todo bool_switch doesn't work with optional unfortunately... 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); } else { desc.add_options()(Derived::name, apply_default(po::value(&value)), Derived::description); } } T get() const { - if (value) { - return value.get(); + if constexpr (std::is_same_v>) { + return value; + } else { + if (value) { + return value.get(); + } + if constexpr (HasDefault()) { + return Derived::defaultvalue; + } + throw std::runtime_error("Setting not set"); } - if constexpr (HasDefault()) { - return Derived::defaultvalue; - } - throw std::runtime_error("Setting not set"); } bool has() const { - // @todo this is not reliable, better use vmap[...].defaulted() - return !!value; + if constexpr (std::is_same_v>) { + return !value.empty(); + } else { + // @todo this is not reliable, better use vmap[...].defaulted() + return !!value; + } } }; @@ -361,12 +375,38 @@ namespace ifcopenshell { static constexpr const char* const description = "Indicates the parameter value for defining step size when evaluating piecewise curves."; static constexpr double defaultvalue = 0.5; // ceiling of this value is used when PiecewiseStepMethod is MinSteps }; + + 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."; + }; + + 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."; + }; + + enum TriangulationMethod { + TRIANGLE_MESH, + POLYHEDRON_WITHOUT_HOLES, + POLYHEDRON_WITH_HOLES + }; + + std::istream& operator>>(std::istream& in, TriangulationMethod& ioo); + + struct TriangulationType : public SettingBase { + static constexpr const char* const name = "triangulation-type"; + static constexpr const char* const description = "Type of planar facet to be emitted"; + static constexpr TriangulationMethod defaultvalue = TRIANGLE_MESH; + }; + + } template class IFC_GEOM_API SettingsContainer { public: - typedef boost::variant, std::set, IteratorOutputOptions, PiecewiseStepMethod, OutputDimensionalityTypes> value_variant_t; + typedef boost::variant, std::set, std::vector, IteratorOutputOptions, PiecewiseStepMethod, OutputDimensionalityTypes, TriangulationMethod> value_variant_t; private: settings_t settings; @@ -453,73 +493,12 @@ namespace ifcopenshell { }; class IFC_GEOM_API Settings : public SettingsContainer< - std::tuple + std::tuple > {}; } } -// namespace ifcopenshell { -// namespace geometry { -// -// class IFC_GEOM_API ConversionSettings { -// public: -// // Tolerances and settings for various geometrical operations: -// enum GeomValue { -// // -// // Default: 0.001m / 1mm -// GV_DEFLECTION_TOLERANCE, -// -// // The length unit used the creation of TopoDS_Shapes, primarily affects the -// // interpretation of IfcCartesianPoints and IfcVector magnitudes -// // DefaultL 1.0 -// GV_LENGTH_UNIT, -// // The plane angle unit used for the creation of TopoDS_Shapes, primarily affects -// // the interpretation of IfcParamaterValues of IfcTrimmedCurves -// // Default: -1.0 (= not set, fist try degrees, then radians) -// GV_PLANEANGLE_UNIT, -// // The precision used in boolean operations, setting this value too low results -// // in artefacts and potentially modelling failures -// // Default: 0.00001 (obtained from IfcGeometricRepresentationContext if available) -// GV_PRECISION, -// // Whether to process shapes of type Face or higher (1) Wire or lower (-1) or all (0) -// GV_DIMENSIONALITY, -// GV_LAYERSET_FIRST, -// GV_DISABLE_BOOLEAN_RESULT, -// GV_NO_WIRE_INTERSECTION_CHECK, -// GV_PRECISION_FACTOR, -// GV_NO_WIRE_INTERSECTION_TOLERANCE, -// GV_DEBUG_BOOLEAN, -// GV_BOOLEAN_ATTEMPT_2D, -// NUM_SETTINGS -// }; -// -// void setValue(GeomValue var, double value); -// -// double getValue(GeomValue var) const; -// -// private: -// std::array values_ = { -// /* deflection_tolerance = */ 0.001, -// // @todo make sure these 'read-only' variables work. -// /* minimal_face_area = */ std::numeric_limits::quiet_NaN(), -// /* max_faces_to_orient = */ -1.0, -// /* ifc_length_unit = */ 1.0, -// /* ifc_planeangle_unit = */ -1.0, -// /* modelling_precision = */ 0.00001, -// /* dimensionality = */ 1., -// /* layerset_first = */ -1., -// /* disable_boolean_result = */ -1. -// /* no_wire_intersection_check = */ -1., -// /* precision_factor = */ 10., -// /* no_wire_intersection_tolerance = */ -1., -// /* boolean_debug_setting = */ -1., -// /* boolean_attempt_2d = */ 1. -// }; -// }; -// } -// } - // @todo find a place namespace IfcGeom { class IFC_GEOM_API geometry_exception : public std::exception { diff --git a/src/ifcgeom/IfcGeomRepresentation.cpp b/src/ifcgeom/IfcGeomRepresentation.cpp index 07ab07fd2c..c888efba2a 100644 --- a/src/ifcgeom/IfcGeomRepresentation.cpp +++ b/src/ifcgeom/IfcGeomRepresentation.cpp @@ -142,8 +142,7 @@ namespace { #endif IfcGeom::Representation::Serialization::Serialization(const BRep& brep) - : Representation(brep.settings(), brep.entity()) - , id_(brep.id()) + : Representation(brep.settings(), brep.entity(), brep.id()) { for (auto it = brep.begin(); it != brep.end(); ++it) { int sid = -1; @@ -323,8 +322,7 @@ bool IfcGeom::Representation::BRep::calculate_projected_surface_area(const ifcop } IfcGeom::Representation::Triangulation::Triangulation(const BRep& shape_model) - : Representation(shape_model.settings(), shape_model.entity()) - , id_(shape_model.id()) + : Representation(shape_model.settings(), shape_model.entity(), shape_model.id()) , weld_offset_(0) { for (IfcGeom::ConversionResults::const_iterator iit = shape_model.begin(); iit != shape_model.end(); ++iit) { @@ -334,23 +332,23 @@ IfcGeom::Representation::Triangulation::Triangulation(const BRep& shape_model) int surface_style_id = -1; if (iit->hasStyle()) { - auto jt = std::find(_materials.begin(), _materials.end(), iit->StylePtr()); - if (jt == _materials.end()) { - surface_style_id = (int)_materials.size(); - _materials.push_back(iit->StylePtr()); + auto jt = std::find(materials_.begin(), materials_.end(), iit->StylePtr()); + if (jt == materials_.end()) { + surface_style_id = (int)materials_.size(); + materials_.push_back(iit->StylePtr()); } else { - surface_style_id = (int)(jt - _materials.begin()); + surface_style_id = (int)(jt - materials_.begin()); } } if (settings().get().get() && surface_style_id == -1) { const auto& material = IfcGeom::get_default_style(shape_model.entity()); - auto mit = std::find(_materials.begin(), _materials.end(), material); - if (mit == _materials.end()) { - surface_style_id = (int)_materials.size(); - _materials.push_back(material); + auto mit = std::find(materials_.begin(), materials_.end(), material); + if (mit == materials_.end()) { + surface_style_id = (int)materials_.size(); + materials_.push_back(material); } else { - surface_style_id = (int)(mit - _materials.begin()); + surface_style_id = (int)(mit - materials_.begin()); } } @@ -395,7 +393,7 @@ int IfcGeom::Representation::Triangulation::addVertex(int item_id, int material_ const double X = convert ? (pX /unit_magnitude) : pX; const double Y = convert ? (pY /unit_magnitude) : pY; const double Z = convert ? (pZ /unit_magnitude) : pZ; - int i = (int)_verts.size() / 3; + int i = (int)verts_.size() / 3; if (settings().get().get()) { const VertexKey key = std::make_tuple(item_id, material_index, X, Y, Z); typename VertexKeyMap::const_iterator it = welds.find(key); @@ -403,13 +401,13 @@ int IfcGeom::Representation::Triangulation::addVertex(int item_id, int material_ i = (int)(welds.size() + weld_offset_); welds[key] = i; } - _verts.push_back(X); - _verts.push_back(Y); - _verts.push_back(Z); + verts_.push_back(X); + verts_.push_back(Y); + verts_.push_back(Z); return i; } -void IfcGeom::Representation::Triangulation::addEdge(int n1, int n2, std::map, int>& edgecount) { +void IfcGeom::Representation::Triangulation::registerEdgeCount(int n1, int n2, std::map, int>& edgecount) { const Edge e = Edge((std::min)(n1, n2), (std::max)(n1, n2)); edgecount[e] ++; } diff --git a/src/ifcgeom/IfcGeomRepresentation.h b/src/ifcgeom/IfcGeomRepresentation.h index a52953d602..23c3398c96 100644 --- a/src/ifcgeom/IfcGeomRepresentation.h +++ b/src/ifcgeom/IfcGeomRepresentation.h @@ -35,35 +35,39 @@ namespace IfcGeom { protected: const ifcopenshell::geometry::Settings settings_; const std::string entity_; + std::string id_; public: - explicit Representation(const ifcopenshell::geometry::Settings& settings, const std::string& entity) + explicit Representation(const ifcopenshell::geometry::Settings& settings, const std::string& entity, const std::string& id) : settings_(settings) , entity_(entity) + , id_(id) {} const ifcopenshell::geometry::Settings& settings() const { return settings_; } const std::string& entity() const { return entity_; } + // id starts with representation id and then it may have the following dash separated elements: + // - layerset-layerset_id + // - material-material_id + // - openings-opening0_id-...-openingN_id + const std::string& id() const { return id_; } virtual ~Representation() {} }; class IFC_GEOM_API BRep : public Representation { private: - std::string id_; const IfcGeom::ConversionResults shapes_; BRep(const BRep& other); BRep& operator=(const BRep& other); public: BRep(const ifcopenshell::geometry::Settings& settings, const std::string& entity, const std::string& id, const IfcGeom::ConversionResults& shapes) - : Representation(settings, entity) - , id_(id) + : Representation(settings, entity, id) , shapes_(shapes) {} virtual ~BRep() {} IfcGeom::ConversionResults::const_iterator begin() const { return shapes_.begin(); } IfcGeom::ConversionResults::const_iterator end() const { return shapes_.end(); } const IfcGeom::ConversionResults& shapes() const { return shapes_; } - const std::string& id() const { return id_; } IfcGeom::ConversionResultShape* as_compound(bool force_meters = false) const; bool calculate_volume(double&) const; @@ -77,7 +81,6 @@ namespace IfcGeom { class IFC_GEOM_API Serialization : public Representation { private: - std::string id_; std::string brep_data_; std::vector surface_styles_; std::vector surface_style_ids_; @@ -87,7 +90,6 @@ namespace IfcGeom { const std::vector& surface_style_ids() const { return surface_style_ids_; } Serialization(const BRep& brep); virtual ~Serialization() {} - const std::string& id() const { return id_; } private: Serialization(); Serialization(const Serialization&); @@ -101,34 +103,42 @@ namespace IfcGeom { typedef std::map VertexKeyMap; typedef std::pair Edge; - std::string id_; - std::vector _verts; - std::vector _faces; - std::vector _edges; - std::vector _normals; + std::vector verts_; + + // @nb only one of these is populated based on settings, we didn't want to go + // all in with templates or subtypes because of reduced ease of use. + std::vector faces_; + std::vector> polyhedral_faces_without_holes_; + std::vector>> polyhedral_faces_with_holes_; + + std::vector edges_; + std::vector normals_; std::vector uvs_; - std::vector _material_ids; - std::vector _materials; - std::vector _item_ids; + std::vector material_ids_; + std::vector materials_; + std::vector item_ids_; + std::vector edges_item_ids_; size_t weld_offset_; VertexKeyMap welds; - Triangulation(const ifcopenshell::geometry::Settings& settings, const std::string& entity) - : Representation(settings, entity) + Triangulation(const ifcopenshell::geometry::Settings& settings, const std::string& entity, const std::string& id) + : Representation(settings, entity, id) , weld_offset_(0) {} public: - const std::string& id() const { return id_; } - const std::vector& verts() const { return _verts; } - const std::vector& faces() const { return _faces; } - const std::vector& edges() const { return _edges; } - const std::vector& normals() const { return _normals; } + const std::vector& verts() const { return verts_; } + const std::vector& faces() const { return faces_; } + const std::vector>& polyhedral_faces_without_holes() const { return polyhedral_faces_without_holes_; } + const std::vector>>& polyhedral_faces_with_holes() const { return polyhedral_faces_with_holes_; } + const std::vector& edges() const { return edges_; } + const std::vector& normals() const { return normals_; } std::vector& uvs() { return uvs_; } const std::vector& uvs() const { return uvs_; } - const std::vector& material_ids() const { return _material_ids; } - const std::vector& materials() const { return _materials; } - const std::vector& item_ids() const { return _item_ids; } + const std::vector& material_ids() const { return material_ids_; } + const std::vector& materials() const { return materials_; } + const std::vector& item_ids() const { return item_ids_; } + const std::vector& edges_item_ids() const { return edges_item_ids_; } Triangulation(const BRep& shape_model); @@ -144,17 +154,18 @@ namespace IfcGeom { const std::vector& material_ids, const std::vector& materials, const std::vector& item_ids + , const std::vector& edges_item_ids ) - : Representation(settings, entity) - , id_(id) - , _verts(verts) - , _faces(faces) - , _edges(edges) - , _normals(normals) + : Representation(settings, entity, id) + , verts_(verts) + , faces_(faces) + , edges_(edges) + , normals_(normals) , uvs_(uvs) - , _material_ids(material_ids) - , _materials(materials) - , _item_ids(item_ids) + , material_ids_(material_ids) + , materials_(materials) + , item_ids_(item_ids) + , edges_item_ids_(edges_item_ids) {} virtual ~Triangulation() {} @@ -163,39 +174,55 @@ namespace IfcGeom { /// @todo Very simple impl. Assumes that input vertices and normals match 1:1. static std::vector box_project_uvs(const std::vector &vertices, const std::vector &normals); - static Triangulation* empty(const ifcopenshell::geometry::Settings& settings) { return new Triangulation(settings, ""); } + static Triangulation* empty(const ifcopenshell::geometry::Settings& settings) { return new Triangulation(settings, "", ""); } /// Welds vertices that belong to different faces int addVertex(int item_index, int material_index, double X, double Y, double Z); void addNormal(double X, double Y, double Z) { - _normals.push_back(X); - _normals.push_back(Y); - _normals.push_back(Z); + normals_.push_back(X); + normals_.push_back(Y); + normals_.push_back(Z); } void addFace(int item_id, int style, int i0, int i1, int i2) { - _faces.push_back(i0); - _faces.push_back(i1); - _faces.push_back(i2); + faces_.push_back(i0); + faces_.push_back(i1); + faces_.push_back(i2); - _item_ids.push_back(item_id); - _material_ids.push_back(style); + item_ids_.push_back(item_id); + material_ids_.push_back(style); } - void addEdge(int style, int i0, int i1) { - _edges.push_back(i0); - _edges.push_back(i1); + void addFace(int item_id, int style, const std::vector& outer_bound) { + polyhedral_faces_without_holes_.push_back(outer_bound); - _material_ids.push_back(style); + item_ids_.push_back(item_id); + material_ids_.push_back(style); } - void registerEdge(int i0, int i1) { - _edges.push_back(i0); - _edges.push_back(i1); + void addFace(int item_id, int style, const std::vector>& bounds) { + polyhedral_faces_with_holes_.push_back(bounds); + + item_ids_.push_back(item_id); + material_ids_.push_back(style); } - void addEdge(int n1, int n2, std::map, int>& edgecount); + void addEdge(int item_id, int style, int i0, int i1) { + edges_.push_back(i0); + edges_.push_back(i1); + + material_ids_.push_back(style); + edges_item_ids_.push_back(item_id); + } + + void registerEdge(int item_id, int i0, int i1) { + edges_.push_back(i0); + edges_.push_back(i1); + edges_item_ids_.push_back(item_id); + } + + void registerEdgeCount(int n1, int n2, std::map, int>& edgecount); void resetWelds() { weld_offset_ += welds.size(); diff --git a/src/ifcgeom/Iterator.h b/src/ifcgeom/Iterator.h index 8bf862def1..242cdb7418 100644 --- a/src/ifcgeom/Iterator.h +++ b/src/ifcgeom/Iterator.h @@ -701,6 +701,10 @@ namespace IfcGeom { /// Gets the representation of the current geometrical entity. Element* get() { + if (!initialization_outcome_) { + throw std::runtime_error("Iterator not initialized"); + } + auto ret = *task_result_iterator_; // If we want to organize the element considering their hierarchy diff --git a/src/ifcgeom/infra_sweep_helper.cpp b/src/ifcgeom/infra_sweep_helper.cpp new file mode 100644 index 0000000000..5456a14fc9 --- /dev/null +++ b/src/ifcgeom/infra_sweep_helper.cpp @@ -0,0 +1,203 @@ +#include "profile_helper.h" +#include "infra_sweep_helper.h" +#include "piecewise_function_evaluator.h" + +#include + +using namespace ifcopenshell::geometry; + +namespace { + // std::lerp when upgrading to C++ 20 + template + T lerp(const T& a, const T& b, double t) { + return a + t * (b - a); + } +} + +taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::piecewise_function::ptr& pwf, std::vector& cross_sections) +{ + std::sort(cross_sections.begin(), cross_sections.end()); + + auto loft = taxonomy::make(); + // @todo intialize as default + loft->axis = nullptr; + + // @todo currently only the case is handled where directrix returns a piecewise_function + // @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a piecewise function + if (pwf) { + piecewise_function_evaluator evaluator(pwf, &settings_); + double start = std::max(0., cross_sections.front().dist_along); + double end = std::min(pwf->length(), cross_sections.back().dist_along); + + if (end - start < 1.e-9) { + Logger::Warning("Empty sweep domain with start at " + std::to_string(cross_sections.front().dist_along) + " end at " + std::to_string(cross_sections.back().dist_along) + " and curve domain length " + std::to_string(pwf->length()), inst); + return nullptr; + } + + auto curve_length = end - start; + auto param_type = settings_.get().get(); + auto param = settings_.get().get(); + size_t num_steps = 0; + if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { + // parameter is max step size + num_steps = (size_t)std::ceil(curve_length / param); + } else { + // parameter is minimum number of steps + num_steps = (size_t)std::ceil(param); + } + std::vector longitudes; + for (auto& x : cross_sections) { + longitudes.push_back(x.dist_along); + } + longitudes.push_back(std::numeric_limits::infinity()); + auto profile_index = longitudes.begin(); + for (size_t i = 0; i <= num_steps; ++i) { + auto dist_along = start + curve_length / num_steps * i; + while (dist_along > *(profile_index + 1)) { + profile_index++; + if (profile_index == longitudes.end()) { + // @todo handle this? + } + } + + 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; + + 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. + bool should_interpolate = + (profile_index + 1 < longitudes.end()) && + (relative_dist_along >= 1.e-9 || offset_a.cwiseAbs().maxCoeff() > 0.); + + if (should_interpolate) { + taxonomy::geom_item::ptr profile_b; + Eigen::Vector3d offset_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; + } else { + profile_b = profile_a; + offset_b = offset_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.); + + if (should_interpolate2) { + + std::vector loops_a, loops_b; + + if (profile_a->kind() == taxonomy::FACE) { + interpolated = taxonomy::make(); + + auto profile_a_f = std::static_pointer_cast(profile_a); + auto profile_b_f = std::static_pointer_cast(profile_b); + + if (profile_a_f->children.size() != profile_b_f->children.size()) { + Logger::Warning("Mismatching number of face boundaries: " + + std::to_string(profile_a_f->children.size()) + " vs " + + std::to_string(profile_b_f->children.size()), + inst + ); + return nullptr; + } + loops_a = profile_a_f->children; + loops_b = profile_b_f->children; + } else { + loops_a = { std::static_pointer_cast(profile_a) }; + loops_b = { std::static_pointer_cast(profile_b) }; + interpolated = taxonomy::make(); + } + + // @todo should_interpolate should also be informed based by different face matrices. + if (profile_a->matrix || profile_b->matrix) { + interpolated->matrix = taxonomy::make(); + Eigen::Matrix4d m4a = Eigen::Matrix4d::Identity(); + Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); + if (profile_a->matrix) { + m4a = profile_a->matrix->ccomponents(); + } + if (profile_b->matrix) { + m4b = profile_b->matrix->ccomponents(); + } + interpolated->matrix->components() = lerp(m4a, m4b, relative_dist_along); + } + + auto interpolated_offset = lerp(offset_a, offset_b, relative_dist_along); + taxonomy::loop::ptr w1, w2; + taxonomy::edge::ptr e1, e2; + for (auto tmp_ : boost::combine(loops_a, loops_b)) { + boost::tie(w1, w2) = tmp_; + if (w1->children.size() != w2->children.size()) { + Logger::Warning("Mismatching number of edges: " + + std::to_string(w1->children.size()) + " vs " + + std::to_string(w2->children.size()), + inst + ); + return nullptr; + } + std::vector points; + for (auto tmp__ : boost::combine(w1->children, w2->children)) { + boost::tie(e1, e2) = tmp__; + auto& p1 = boost::get(e1->start); + auto& p2 = boost::get(e2->start); + + auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + points.push_back(taxonomy::make(p3)); + } + if (!points.empty()) { + // close polygon by referencing first point + // @todo add a closed=true|false to polygon_from_points()? + points.push_back(points.front()); + } + + auto interpolated_loop = polygon_from_points(points); + if (interpolated->kind() == taxonomy::FACE) { + std::static_pointer_cast(interpolated)->children.push_back(interpolated_loop); + } else { + std::static_pointer_cast(interpolated)->children = interpolated_loop->children; + } + } + } + } + + auto m4 = evaluator.evaluate(dist_along); + /* { + std::wcout << "#" << pwf->instance->data().id() << " " << dist_along << ": " << m4.col(3).row(2).value() << std::endl; + }*/ + + 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(); + m4b.col(3).head<3>() = m4.col(3).head<3>(); + + if (interpolated) { + loft->children.push_back(interpolated); + } else { + if (profile_a->kind() == taxonomy::FACE) { + loft->children.push_back(std::static_pointer_cast(taxonomy::item::ptr(profile_a->clone_()))); + } else { + loft->children.push_back(std::static_pointer_cast(taxonomy::item::ptr(profile_a->clone_()))); + } + if (profile_a->matrix) { + loft->children.back()->matrix = taxonomy::matrix4::ptr(profile_a->matrix->clone_()); + } + } + if (!loft->children.back()->matrix) { + // @todo should this not be initialized by default? matrix4 already has a 'lazy identity' mechanism. + loft->children.back()->matrix = taxonomy::make(); + } + auto m = (m4b * loft->children.back()->matrix->ccomponents()).eval(); + loft->children.back()->matrix->components() = m; + } + } + + return loft; +} diff --git a/src/ifcgeom/infra_sweep_helper.h b/src/ifcgeom/infra_sweep_helper.h new file mode 100644 index 0000000000..41b38e6c9a --- /dev/null +++ b/src/ifcgeom/infra_sweep_helper.h @@ -0,0 +1,26 @@ +#ifndef LINEAR_SWEEP_HELPER_H +#define LINEAR_SWEEP_HELPER_H + +#include "taxonomy.h" +#include "ConversionSettings.h" + +namespace ifcopenshell { + + namespace geometry { + + struct cross_section { + double dist_along; + taxonomy::geom_item::ptr section_geometry; + Eigen::Vector3d offset; + + bool operator <(const cross_section& other) const { + return dist_along < other.dist_along; + } + }; + + taxonomy::loft::ptr make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::piecewise_function::ptr& directrix, std::vector& cross_sections); + } + +} + +#endif \ No newline at end of file diff --git a/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp b/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp index 70b10e83d4..7a93bd00a9 100644 --- a/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp +++ b/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp @@ -22,6 +22,40 @@ using ifcopenshell::geometry::NumberEpeck; ifcopenshell::geometry::CgalShape::CgalShape(const cgal_shape_t & shape, bool convex) { shape_ = shape; convex_tag_ = convex; + + for (const auto& face : CGAL::faces(*shape_)) { + // @todo O^2 alert! Use aabb tree or box intersections + bool has_self_intersection = false; + for (auto& he1 : CGAL::halfedges_around_face(face->halfedge(), *shape_)) { + CGAL::Segment_3 s1; + { + const auto& source = he1->vertex()->point(); + const auto& target = he1->next()->vertex()->point(); + s1 = { source, target }; + } + for (auto& he2 : CGAL::halfedges_around_face(face->halfedge(), *shape_)) { + if (he1 == he2 || he1->next() == he2 || he2->next() == he1) { + // skip topologically connected edges + continue; + } + CGAL::Segment_3 s2; + { + const auto& source = he2->vertex()->point(); + const auto& target = he2->next()->vertex()->point(); + s2 = { source, target }; + } + if (CGAL::do_intersect(s1, s2)) { + has_self_intersection = true; + break; + } + } + } + + if (has_self_intersection) { + throw std::runtime_error("Self-intersection in facet boundary, not attempting triangulation"); + } + } + if (shape.size_of_facets() != 1) { // this is for handling the specical case of storing a single point in a polyhedron, // @todo come up with a proper variant for storing lower dimensional entities diff --git a/src/ifcgeom/kernels/cgal/CgalKernel.cpp b/src/ifcgeom/kernels/cgal/CgalKernel.cpp index 4c41d22aff..86ceaabe7d 100644 --- a/src/ifcgeom/kernels/cgal/CgalKernel.cpp +++ b/src/ifcgeom/kernels/cgal/CgalKernel.cpp @@ -153,6 +153,12 @@ CGAL::Nef_polyhedron_3 ifcopenshell::geometry::utils::create_nef_polyhe #endif bool CgalKernel::convert(const taxonomy::shell::ptr l, cgal_shape_t& shape) { + for (auto& f : l->children) { + if (f->basis && f->basis->kind() != taxonomy::PLANE) { + Logger::Error("Non-planar faces not supported at the moment"); + return false; + } + } if (false && l->children.size() > 100) { static double inf = 1.e9; // std::numeric_limits::infinity(); std::pair minmax( @@ -784,12 +790,16 @@ bool CgalKernel::convert_impl(const taxonomy::shell::ptr shell, ConversionResult } bool CgalKernel::convert_impl(const taxonomy::solid::ptr solid, ConversionResults& results) { + if (solid->children.size() > 1) { + Logger::Error("Multiple shells in solid not supported at the moment"); + return false; + } cgal_shape_t shape; if (solid->children.empty()) { return false; } // @todo - if (!convert((taxonomy::shell::ptr)solid->children[0], shape)) { + if (!convert(solid->children[0], shape)) { return false; } if (shape.size_of_facets() == 0) { diff --git a/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.cpp b/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.cpp index 1660f5928b..d5e6e2c61a 100644 --- a/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.cpp +++ b/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "OpenCascadeConversionResult.h" @@ -15,6 +16,12 @@ #include +#include +#include +#include +#include +#include + #if OCC_VERSION_HEX >= 0x70600 #include #endif @@ -24,6 +31,44 @@ using IfcGeom::OpaqueCoordinate; using IfcGeom::NumberNativeDouble; using IfcGeom::ConversionResultShape; +struct EdgeKey { + int v1, v2; + + // These are not part of the hash or equality, + // but retained to easily created a directed + // graph of the original boundary edges. Since + // the boundary edges are exactly those with + // count=1 we don't need to worry about + // conflicting original vertex indices. + int ov1, ov2; + + EdgeKey(int a, int b) + : ov1(a) + , ov2(b) + { + if (a < b) { + v1 = a; + v2 = b; + } else { + v1 = b; + v2 = a; + } + } + + bool operator==(const EdgeKey& other) const { + return v1 == other.v1 && v2 == other.v2; + } +}; + +namespace std { + template <> + struct hash { + std::size_t operator()(const EdgeKey& ek) const { + return std::hash()(ek.v1) ^ std::hash()(ek.v2); + } + }; +} + namespace { // We bypass the conversion to gp_GTrsf, because it does not work void taxonomy_transform(const Eigen::Matrix4d* m, gp_XYZ& xyz) { @@ -35,6 +80,78 @@ namespace { xyz.ChangeData()[2] = v2(2); } } + + // Function to find boundary loops from triangles + std::vector> find_boundary_loops(const std::vector& positions, const std::vector>& triangles) { + std::unordered_map edge_count; + + // Count how many triangles each edge belongs to + for (const auto& triangle : triangles) { + int v1, v2, v3; + std::tie(v1, v2, v3) = triangle; + + edge_count[{v1, v2}]++; + edge_count[{v2, v3}]++; + edge_count[{v3, v1}]++; + } + + // Boundary edges have count 1 + std::vector boundary_edges; + for (auto& p : edge_count) { + if (p.second == 1) { + boundary_edges.push_back(p.first); + } + } + + // We retained original directed edges so we build + // a mapping out of these directed edges. + std::unordered_map vertex_successors; + for (const auto& e : boundary_edges) { + vertex_successors[e.ov1] = e.ov2; + } + + std::vector> loops; + while (!vertex_successors.empty()) { + loops.emplace_back(); + auto it = vertex_successors.begin(); + loops.back() = { it->first, it->second }; + vertex_successors.erase(it); + + int current = loops.back().back(); + while (!vertex_successors.empty() && current != loops.back().front()) { + auto next = vertex_successors[current]; + if (loops.back().front() != next) { + loops.back().push_back(next); + } + vertex_successors.erase(current); + current = next; + } + } + + // Sort the loops by smallest x-coord of their constituent positions + // In order to put the outermost loop in front + if (loops.size() > 1) { + std::vector> min_xs; + for (auto& l : loops) { + double min_x = std::numeric_limits::infinity(); + for (auto& i : l) { + const auto& x = positions[i * 3]; + if (x < min_x) { + min_x = x; + } + } + min_xs.push_back({ min_x, min_xs.size() }); + } + std::sort(min_xs.begin(), min_xs.end()); + decltype(loops) loops_copy; + for (auto& p : min_xs) { + loops_copy.emplace_back(std::move(loops[p.second])); + } + std::swap(loops, loops_copy); + } + + return loops; + } } void ifcopenshell::geometry::OpenCascadeShape::Triangulate(ifcopenshell::geometry::Settings settings, const ifcopenshell::geometry::taxonomy::matrix4& place, IfcGeom::Representation::Triangulation* t, int item_id, int surface_style_id) const { @@ -71,6 +188,18 @@ void ifcopenshell::geometry::OpenCascadeShape::Triangulate(ifcopenshell::geometr TopExp_Explorer exp; for (exp.Init(shape_, TopAbs_FACE); exp.More(); exp.Next(), ++num_faces) { TopoDS_Face face = TopoDS::Face(exp.Current()); + + size_t num_bounds = 0; + for (TopoDS_Iterator it(face); it.More(); it.Next(), ++num_bounds) {} + + const bool is_planar = BRep_Tool::Surface(face) && BRep_Tool::Surface(face)->DynamicType() == STANDARD_TYPE(Geom_Plane); + const bool has_inner_bounds = num_bounds > 1; + + const bool polyhedral_output_with_holes = settings.get().get() == settings::POLYHEDRON_WITH_HOLES && is_planar; + const bool polyhedral_output_without_holes = settings.get().get() == settings::POLYHEDRON_WITHOUT_HOLES && is_planar && !has_inner_bounds; + + std::vector> triangle_indices; + TopLoc_Location loc; Handle_Poly_Triangulation tri = BRep_Tool::Triangulation(face, loc); @@ -154,17 +283,27 @@ void ifcopenshell::geometry::OpenCascadeShape::Triangulate(ifcopenshell::geometr _normals.push_back((float)normal.Z()); */ - t->addFace(item_id, surface_style_id, dict[n1], dict[n2], dict[n3]); + if (polyhedral_output_without_holes || polyhedral_output_with_holes) { + triangle_indices.push_back({ dict[n1], dict[n2], dict[n3] }); + } else { + if (settings.get().get() == settings::POLYHEDRON_WITHOUT_HOLES) { + t->addFace(item_id, surface_style_id, std::vector{ dict[n1], dict[n2], dict[n3] }); + } else if (settings.get().get() == settings::POLYHEDRON_WITH_HOLES) { + t->addFace(item_id, surface_style_id, std::vector>{{ dict[n1], dict[n2], dict[n3] }}); + } else { + t->addFace(item_id, surface_style_id, dict[n1], dict[n2], dict[n3]); - t->addEdge(dict[n1], dict[n2], edgecount); - t->addEdge(dict[n2], dict[n3], edgecount); - t->addEdge(dict[n3], dict[n1], edgecount); + t->registerEdgeCount(dict[n1], dict[n2], edgecount); + t->registerEdgeCount(dict[n2], dict[n3], edgecount); + t->registerEdgeCount(dict[n3], dict[n1], edgecount); + } + } } for (auto& p : edgecount) { // @todo should be != 2? if (p.second == 1 && emitted_edges.find(p.first) == emitted_edges.end()) { // non manifold edge, face boundary - t->registerEdge(p.first.first, p.first.second); + t->registerEdge(item_id, p.first.first, p.first.second); if (settings.get().get()) { // only relevant while welding, because otherwise vertices are not shared among distinct faces emitted_edges.insert(p.first); @@ -172,6 +311,19 @@ void ifcopenshell::geometry::OpenCascadeShape::Triangulate(ifcopenshell::geometr } } } + + if (polyhedral_output_without_holes || polyhedral_output_with_holes) { + auto loops = find_boundary_loops(t->verts(), triangle_indices); + if (polyhedral_output_without_holes) { + if (!loops.empty() && !loops[0].empty()) { + t->addFace(item_id, surface_style_id, loops[0]); + } + } else { + if (!loops.empty()) { + t->addFace(item_id, surface_style_id, loops); + } + } + } } if (!t->normals().empty() && settings.get().get()) { @@ -234,7 +386,7 @@ void ifcopenshell::geometry::OpenCascadeShape::Triangulate(ifcopenshell::geometr } for (auto& sgmt : segments) { - t->addEdge(surface_style_id, sgmt.first, sgmt.second); + t->addEdge(item_id, surface_style_id, sgmt.first, sgmt.second); } previous = current; diff --git a/src/ifcgeom/kernels/opencascade/boolean_utils.cpp b/src/ifcgeom/kernels/opencascade/boolean_utils.cpp index 9347dec615..bc164de35d 100644 --- a/src/ifcgeom/kernels/opencascade/boolean_utils.cpp +++ b/src/ifcgeom/kernels/opencascade/boolean_utils.cpp @@ -978,7 +978,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To return true; } - if (Logger::LOG_NOTICE >= Logger::Verbosity()) { + if (!is_2d && Logger::LOG_NOTICE >= Logger::Verbosity()) { PERF("preliminary manifoldness check"); if (!a.IsNull()) { diff --git a/src/ifcgeom/kernels/opencascade/face.cpp b/src/ifcgeom/kernels/opencascade/face.cpp index c369e85387..a74bd33fc8 100644 --- a/src/ifcgeom/kernels/opencascade/face.cpp +++ b/src/ifcgeom/kernels/opencascade/face.cpp @@ -442,7 +442,7 @@ bool OpenCascadeKernel::convert(const taxonomy::face::ptr face, TopoDS_Shape& re kt.Value().Original().ToUTF8CString(c); std::string message = c; delete[] c; -#if OCC_VERSION_MAJOR==7 && OCC_VERSION_MINOR == 7 +#if OCC_VERSION_MAJOR==7 && OCC_VERSION_MINOR >= 7 if (!reversed_surface && !fd.surface().IsNull() && fd.surface()->IsUPeriodic() && message == "Unknown message invoked with the keyword FixAdvFace.FixOrientation.MSG0") { Logger::Notice("Detected reversed wire, reattempting with reversed basis surface"); TopoDS_Face reversed_result; diff --git a/src/ifcgeom/kernels/opencascade/faceset_helper.cpp b/src/ifcgeom/kernels/opencascade/faceset_helper.cpp index 3436b67bbd..f6ebd9c7ec 100644 --- a/src/ifcgeom/kernels/opencascade/faceset_helper.cpp +++ b/src/ifcgeom/kernels/opencascade/faceset_helper.cpp @@ -115,6 +115,7 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper( eps_ = Precision::Confusion(); } + std::vector retained(pnts.size()); for (int pnt_i = 0; pnt_i < (int)pnts.size(); ++pnt_i) { if (pnts[pnt_i]) { std::set vs; @@ -125,7 +126,9 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper( // NB: insert() ignores duplicate keys // v-1? // @todo this reliable also in case of tesselations? - vertex_mapping_.insert({ pt.identity(), pnt_i }); + if (vertex_mapping_.insert({ pt.identity(), pnt_i }).second) { + retained[pnt_i] = true; + } } } } @@ -141,8 +144,10 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper( } } - if (unique.size() != vertex_mapping_.size()) { - Logger::Notice("Collapsed vertices from " + std::to_string(pnts.size()) + " (" + std::to_string(unique.size()) + " unique) to " + std::to_string(vertex_mapping_.size())); + auto num_retained = std::count(retained.begin(), retained.end(), true); + + if (unique.size() != num_retained) { + Logger::Notice("Collapsed vertices from " + std::to_string(pnts.size()) + " (" + std::to_string(unique.size()) + " unique) to " + std::to_string(num_retained)); } typedef std::array edge_t; diff --git a/src/ifcgeom/kernels/opencascade/loft.cpp b/src/ifcgeom/kernels/opencascade/loft.cpp index b418675fbd..70e174fe01 100644 --- a/src/ifcgeom/kernels/opencascade/loft.cpp +++ b/src/ifcgeom/kernels/opencascade/loft.cpp @@ -48,26 +48,43 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re for (auto it = loft->children.begin(); it < loft->children.end() - 1; ++it) { auto jt = it + 1; - std::array fa = { *it, *jt }; + std::array fa = { *it, *jt }; std::array shps; std::array ws; for (int i = 0; i < 2; ++i) { - if (!convert(fa[i], shps[i])) { - return false; + if (fa[i]->kind() == taxonomy::FACE) { + if (!convert(std::static_pointer_cast(fa[i]), shps[i])) { + return false; + } } - if (shps[i].ShapeType() != TopAbs_FACE) { + if (fa[i]->kind() == taxonomy::LOOP) { + TopoDS_Wire w; + if (!convert(std::static_pointer_cast(fa[i]), w)) { + return false; + } + shps[i] = w; + } + if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) { return false; } // @todo this is only outer wire - ws[i] = BRepTools::OuterWire(TopoDS::Face(shps[i])); + if (shps[i].ShapeType() == TopAbs_FACE) { + ws[i] = BRepTools::OuterWire(TopoDS::Face(shps[i])); + } else { + ws[i] = TopoDS::Wire(shps[i]); + } } - if (it == loft->children.begin()) { - // faces.Append(shps[0]); - BB.Add(comp, shps[0]); - } - if (jt == loft->children.end() - 1) { - // faces.Append(shps[1]); - BB.Add(comp, shps[1]); + if (shps[0].ShapeType() == TopAbs_FACE) { + // When processing a sectioned *surface* there are no + // begin and end caps that need to be added. + if (it == loft->children.begin()) { + // faces.Append(shps[0]); + BB.Add(comp, shps[0]); + } + if (jt == loft->children.end() - 1) { + // faces.Append(shps[1]); + BB.Add(comp, shps[1]); + } } BRepTools_WireExplorer a(ws[0]); BRepTools_WireExplorer b(ws[1]); diff --git a/src/ifcgeom/mapping.cpp b/src/ifcgeom/mapping.cpp deleted file mode 100644 index c472e8dd1e..0000000000 --- a/src/ifcgeom/mapping.cpp +++ /dev/null @@ -1,169 +0,0 @@ -/******************************************************************************** -* * -* This file is part of IfcOpenShell. * -* * -* IfcOpenShell is free software: you can redistribute it and/or modify * -* it under the terms of the Lesser GNU General Public License as published by * -* the Free Software Foundation, either version 3.0 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 * -* Lesser GNU General Public License for more details. * -* * -* You should have received a copy of the Lesser GNU General Public License * -* along with this program. If not, see . * -* * -********************************************************************************/ - -/* -#include "IfcGeom.h" -#include "../ifcgeom_schema_agnostic/IfcGeomShapeType.h" -#include "../ifcgeom_schema_agnostic/wire_utils.h" - -#include -#include - -#define Kernel POSTFIX_SCHEMA(Kernel) - -using namespace IfcUtil; - -bool IfcGeom::Kernel::convert_shapes(const IfcBaseInterface* l, ConversionResults& r) { - if (shape_type(l) != ST_SHAPELIST) { - TopoDS_Shape shp; - if (convert_shape(l, shp)) { - std::shared_ptr style; - if (l->as()) { - style = get_style(l->as()); - } - r.push_back(IfcGeom::ConversionResult(l->data().id(), shp, style)); - return true; - } - return false; - } - -#include "mapping_shapes.i" - Logger::Message(Logger::LOG_ERROR,"No operation defined for:",l); - return false; -} - -IfcGeom::ShapeType IfcGeom::Kernel::shape_type(const IfcBaseInterface* l) { -#include "mapping_shape_type.i" - return ST_OTHER; -} - -bool IfcGeom::Kernel::convert_shape(const IfcBaseInterface* l, TopoDS_Shape& r) { - const unsigned int id = l->data().id(); - bool success = false; - bool processed = false; - bool ignored = false; - -#ifndef NO_CACHE - std::map::const_iterator it = cache.Shape.find(id); - if ( it != cache.Shape.end() ) { r = it->second; return true; } -#endif - const bool include_curves = getValue(GV_DIMENSIONALITY) != +1; - const bool include_solids_and_surfaces = getValue(GV_DIMENSIONALITY) != -1; - - IfcGeom::ShapeType st = shape_type(l); - ignored = (!include_solids_and_surfaces && (st == ST_SHAPE || st == ST_FACE)) || (!include_curves && (st == ST_WIRE || st == ST_CURVE)); - if (st == ST_SHAPELIST) { - processed = true; - ConversionResults items; - success = convert_shapes(l, items) && util::flatten_shape_list(items, r, false, getValue(GV_PRECISION)); - } else if (st == ST_SHAPE && include_solids_and_surfaces) { -#include "mapping_shape.i" - } else if (st == ST_FACE && include_solids_and_surfaces) { - processed = true; - success = convert_face(l, r); - } else if (st == ST_WIRE && include_curves) { - processed = true; - TopoDS_Wire w; - success = convert_wire(l, w); - if (success) { - r = w; - } - } else if (st == ST_CURVE && include_curves) { - processed = true; - Handle(Geom_Curve) crv; - TopoDS_Wire w; - success = convert_curve(l, crv) && util::convert_curve_to_wire(crv, w); - if (success) { - r = w; - } - } - - if ( processed && success ) { -#ifndef NO_CACHE - cache.Shape[id] = r; -#endif - - if (Logger::LOG_DEBUG >= Logger::Verbosity()) { - std::stringstream ss; - - BRepCheck_Analyzer ana(r); - - std::function traverse_subshapes; - - traverse_subshapes = [&traverse_subshapes, &ana, &ss](const TopoDS_Shape& shape) { - if (shape.IsNull()) - return; - - TopoDS_Iterator it(shape); - for (; it.More(); it.Next()) { - const TopoDS_Shape& subs = it.Value(); - - auto rs = ana.Result(subs); - if (rs) { - for (auto& msg : rs->Status()) { - if (msg != BRepCheck_NoError) { - ss << " "; - std::stringstream sst; - BRepCheck::Print(msg, sst); - auto sss = sst.str(); - // remove trailing newline added by Print() - ss << sss.substr(0, sss.size() - 1); - } - } - } - - traverse_subshapes(subs); - } - }; - - traverse_subshapes(r); - - Logger::Notice((ana.IsValid() ? "Valid shape" : "Invalid shape with:") + ss.str(), l); - } - } else if (!ignored) { - const char* const msg = processed - ? "Failed to convert:" - : "No operation defined for:"; - Logger::Message(Logger::LOG_ERROR, msg, l); - } - return success; -} - -bool IfcGeom::Kernel::convert_wire(const IfcBaseInterface* l, TopoDS_Wire& r) { -#include "mapping_wire.i" - Handle(Geom_Curve) curve; - if (IfcGeom::Kernel::convert_curve(l, curve)) { - return util::convert_curve_to_wire(curve, r); - } - Logger::Message(Logger::LOG_ERROR,"No operation defined for:",l); - return false; -} - -bool IfcGeom::Kernel::convert_face(const IfcBaseInterface* l, TopoDS_Shape& r) { -#include "mapping_face.i" - Logger::Message(Logger::LOG_ERROR,"No operation defined for:",l); - return false; -} - -bool IfcGeom::Kernel::convert_curve(const IfcBaseInterface* l, Handle(Geom_Curve)& r) { -#include "mapping_curve.i" - Logger::Message(Logger::LOG_ERROR,"No operation defined for:",l); - return false; -} -*/ \ No newline at end of file diff --git a/src/ifcgeom/mapping/IfcCircle.cpp b/src/ifcgeom/mapping/IfcCircle.cpp index 6693cd18ca..754c62df62 100644 --- a/src/ifcgeom/mapping/IfcCircle.cpp +++ b/src/ifcgeom/mapping/IfcCircle.cpp @@ -34,5 +34,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCircle* inst) { auto c = taxonomy::make(); c->radius = r; c->matrix = taxonomy::cast(map(placement)); + return c; } diff --git a/src/ifcgeom/mapping/IfcCompositeCurve.cpp b/src/ifcgeom/mapping/IfcCompositeCurve.cpp index b9f2aaaf4d..568b861ad7 100644 --- a/src/ifcgeom/mapping/IfcCompositeCurve.cpp +++ b/src/ifcgeom/mapping/IfcCompositeCurve.cpp @@ -92,7 +92,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCompositeCurve* inst) { return loop; } else { - auto pwf = taxonomy::make(0.0,pwfs,&settings_,inst); + auto pwf = taxonomy::make(0.0,pwfs,inst); return pwf; } } diff --git a/src/ifcgeom/mapping/IfcCurveSegment.cpp b/src/ifcgeom/mapping/IfcCurveSegment.cpp index d50112200a..e2f38135e3 100644 --- a/src/ifcgeom/mapping/IfcCurveSegment.cpp +++ b/src/ifcgeom/mapping/IfcCurveSegment.cpp @@ -55,7 +55,7 @@ double translate_to_length_measure(const IfcSchema::IfcCurve* crv, double param_ return fabs(clothoid->ClothoidConstant()*sqrt(PI))*param_value; } else if (auto circ = crv->as()) { return circ->Radius() * param_value; - } else if (auto circ = crv->as()) { + } else if (auto poly = crv->as()) { return param_value; } else { throw std::runtime_error("Unsupported curve measure type"); @@ -130,6 +130,26 @@ class curve_segment_evaluator { if (next_inst) { next_segment_placement_ = taxonomy::cast(mapping_->map(next_inst->Placement()))->ccomponents(); + } else { + // there is not a next segment, however IfcGradientCurve and IfcSegmentReferenceCurve have an + // optional EndPoint which services the same purpose as the zero-length last segment. + auto composite_curves = inst->UsingCurves(); + IfcSchema::IfcPlacement* end_point = nullptr; + if (composite_curves->size() == 1) { + auto& cc = *(composite_curves)->begin(); + if (segment_type_ == ST_VERTICAL) { + auto gradient_curve = cc->as(); + end_point = gradient_curve->EndPoint(); + } else if (segment_type_ == ST_CANT) { + auto segmented_reference_curve = cc->as(); + end_point = segmented_reference_curve->EndPoint(); + } + } else { + Logger::Warning("IfcCurveSegment belongs to multiple IfcCompositeCurve instances. Cannot determine the end point."); + } + if (end_point) { + next_segment_placement_ = taxonomy::cast(mapping_->map(end_point))->ccomponents(); + } } } @@ -232,43 +252,71 @@ class curve_segment_evaluator { } // defines the parent_curve_fn_ functor for cant segments. - // Cant returns D at a distance along the curve, u. - // CantSlope returns the slope of the Cant function at u. CantSlope(u) is the derivative of Cant(u) - void set_cant_spiral_function(std::function Cant, std::function CantSlope) { - parent_curve_fn_ = [Cant, CantSlope](double u) -> Eigen::Matrix4d { - auto cant = Cant(u); - auto slope = CantSlope(u); + void set_cant_spiral_function(std::function Superelevation, std::function SuperelevationSlope, std::function Cant) { + auto dy = (*placement_)(1, 2); // placement dy + auto dz = (*placement_)(2, 2); // placement dz + auto start_angle = atan2(dz, dy); - auto angle = atan(slope); - auto dx = cos(angle); - auto dy = sin(angle); + dy = (next_segment_placement_.has_value() ? (*next_segment_placement_)(1, 2) : 0.0); + dz = (next_segment_placement_.has_value() ? (*next_segment_placement_)(2, 2) : 1.0); + auto end_angle = atan2(dz, dy); - 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(0.0, cant, 0.0, 1.0); - return m; + auto delta_angle = end_angle - start_angle; + auto start_cant = Cant(0.0 /*start_*/); + auto end_cant = Cant(/* start_ + */ length_); + auto delta_cant = end_cant - start_cant; + + parent_curve_fn_ = [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); + + // 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); + + 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; }; parent_curve_start_point_ = (*parent_curve_fn_)(0.0); } - boost::optional> get_cant_superelevation_function() { - boost::optional> fn; + // returns function for super elevation and the slope of the super elevation curve if the super elevation is constant + // over the length of the segment. otherwise, no functions are returned because they are the same as the cant tilt angle + // functions. + std::pair>, boost::optional>> get_superelevation_functions() { + boost::optional> superelevation_fn; + boost::optional> superelevation_slope_fn; - if (next_segment_placement_.has_value() && placement_.has_value()) { - // cant superelevation should be y value (row 1) + if (placement_.has_value() && next_segment_placement_.has_value()) { double y1 = (*placement_)(1, 3); double y2 = (*next_segment_placement_)(1, 3); - // if y2-y1 = 0, there is no superelevation - // so we need a Cant function that always returns zero + // if y2-y1 = 0, the super elevation is constant + // so we need a function that always returns the constant value if (!(y2 - y1)) { - fn = [](double)->double { return 0.0; }; + superelevation_fn = [y1](double) -> double { return y1; }; + superelevation_slope_fn = [](double) -> double { return 0.0; }; } } - return fn; + return std::make_pair(superelevation_fn,superelevation_slope_fn); } #ifdef SCHEMA_HAS_IfcClothoid @@ -276,15 +324,19 @@ class curve_segment_evaluator { auto A = c->ClothoidConstant(); if (segment_type_ == ST_CANT) { + boost::optional> super, slope; + std::tie(super, slope) = get_superelevation_functions(); + auto cant = [A, L = length_ * length_unit_](double t) -> double { return A ? L * A * t / fabs(pow(A, 3)) : 0.0; }; - auto Cant = get_cant_superelevation_function(); // fn that always returns zero if there is no superelevation - if (!Cant.has_value()) { - // function not provided so there must be a superelevation - this function provides the superelevation transition - Cant = [A, L = length_ * length_unit_](double t) -> double { return A ? L * A * t / fabs(pow(A, 3)) : 0.0; }; + if (!super.has_value()) { + super = cant; } - auto CantSlope = [A, L = length_ * length_unit_](double /*t*/) -> double { return A ? L * A / fabs(pow(A, 3)) : 0.0; }; - set_cant_spiral_function(*Cant, CantSlope); + if (!slope.has_value()) { + slope= [A, L = length_ * length_unit_](double /*t*/) -> double { return A ? L * A / fabs(pow(A, 3)) : 0.0; }; + } + + set_cant_spiral_function(*super,*slope, cant); } else { 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; }; @@ -311,21 +363,27 @@ class curve_segment_evaluator { double s = 1.0; set_spiral_function(s, fn_x, fn_y); } else if (segment_type_ == ST_CANT) { - auto Cant = get_cant_superelevation_function(); // fn that always returns zero if there is no superelevation - if (!Cant.has_value()) { - // function not provided so there must be a superelevation - this function provides the superelevation transition - Cant = [constant_term, cosine_term, L, lu = length_unit_](double t) -> double { - auto a0 = constant_term.has_value() ? L / (constant_term.value() * lu) : 0.0; - auto a1 = (L / (cosine_term * lu)) * cos(PI * t * lu / L); - return a0 + a1; + boost::optional> super, slope; + std::tie(super, slope) = get_superelevation_functions(); + + auto cant = [constant_term, cosine_term, L, lu = length_unit_](double t) -> double { + auto a0 = constant_term.has_value() ? L / (constant_term.value() * lu) : 0.0; + auto a1 = (L / (cosine_term * lu)) * cos(PI * t * lu / L); + return a0 + a1; + }; + + if (!super.has_value()) { + super = cant; + } + + if (!slope.has_value()) { + slope = [cosine_term, L, lu = length_unit_](double t) -> double { + auto a1 = -(PI / L) * (L / (cosine_term * lu)) * sin(PI * t * lu / L); + return a1; }; } - auto CantSlope = [cosine_term, L, lu = length_unit_](double t) -> double { - auto a1 = -(PI / L) * (L / (cosine_term * lu)) * sin(PI * t * lu / L); - return a1; - }; - set_cant_spiral_function(*Cant, CantSlope); + 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_ = [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }; @@ -354,23 +412,29 @@ class curve_segment_evaluator { double s = 1.0; set_spiral_function(s, fn_x, fn_y); } else if (segment_type_ == ST_CANT) { - auto Cant = get_cant_superelevation_function(); // fn that always returns zero if there is no superelevation - if (!Cant.has_value()) { - // function not provided so there must be a superelevation - this function provides the superelevation transition - Cant = [constant_term, linear_term, sine_term, L, lu = length_unit_](double t) -> double { - auto a0 = constant_term.has_value() ? L / (constant_term.value() * lu) : 0.0; - auto a1 = linear_term.has_value() ? sign(linear_term.value()) * pow(L / (linear_term.value() * lu), 2.0) * (t / L) : 0.0; - auto a2 = (L / (sine_term * lu)) * sin(2 * PI * t / L); - return a0 + a1 + a2; + boost::optional> super, slope; + std::tie(super, slope) = get_superelevation_functions(); + + auto cant = [constant_term, linear_term, sine_term, L, lu = length_unit_](double t) -> double { + auto a0 = constant_term.has_value() ? L / (constant_term.value() * lu) : 0.0; + auto a1 = linear_term.has_value() ? sign(linear_term.value()) * pow(L / (linear_term.value() * lu), 2.0) * (t / L) : 0.0; + auto a2 = (L / (sine_term * lu)) * sin(2 * PI * t / L); + return a0 + a1 + a2; + }; + + if (!super.has_value()) { + super = cant; + } + + if (!slope.has_value()) { + slope = [linear_term, sine_term, L, lu = length_unit_](double t) -> double { + auto a1 = linear_term.has_value() ? sign(linear_term.value()) * pow(L / (linear_term.value() * lu), 2.0) * (1.0 / L) : 0.0; + auto a2 = (2 * PI / L) * (L / (sine_term * lu)) * cos(2 * PI * t / L); + return a1 + a2; }; } - auto CantSlope = [linear_term, sine_term, L, lu = length_unit_](double t) -> double { - auto a1 = linear_term.has_value() ? sign(linear_term.value()) * pow(L / (linear_term.value() * lu), 2.0) * (1.0 / L) : 0.0; - auto a2 = (2 * PI / L) * (L / (sine_term * lu)) * cos(2 * PI * t / L); - return a1 + a2; - }; - set_cant_spiral_function(*Cant, CantSlope); + 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_ = [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }; @@ -402,36 +466,41 @@ class curve_segment_evaluator { } 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) { - auto Cant = get_cant_superelevation_function(); // fn that always returns zero if there is no superelevation - if (!Cant.has_value()) { - // function not provided so there must be a superelevation - this function provides the superelevation transition - Cant = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_ * length_unit_, L = length_ * length_unit_, lu = length_unit_, length = length_](double t) { + boost::optional> super, slope; + std::tie(super, slope) = get_superelevation_functions(); + + auto cant = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_ * length_unit_, L = length_ * length_unit_, lu = length_unit_, length = length_](double t) { + 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); + }; + + if (!super.has_value()) { + super = cant; + } + + if (!slope.has_value()) { + slope = [A1, A2, A3, A4, A5, A6, A7, start = start_ * length_unit_, L = length_ * length_unit_, lu = length_unit_, length = length_](double t) { 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); + 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; + auto a3 = A3.has_value() ? 3 * A3.value() * lu * std::pow(t, 2) / fabs(std::pow(A3.value() * lu, 5)) : 0.0; + auto a4 = A4.has_value() ? 4 * std::pow(t, 3) / std::pow(A4.value() * lu, 5) : 0.0; + auto a5 = A5.has_value() ? 5 * A5.value() * lu * std::pow(t, 4) / fabs(std::pow(A5.value() * lu, 7)) : 0.0; + auto a6 = A6.has_value() ? 6 * std::pow(t, 5) / std::pow(A6.value() * lu, 7) : 0.0; + auto a7 = A7.has_value() ? 7 * A7.value() * lu * std::pow(t, 6) / fabs(std::pow(A7.value() * lu, 9)) : 0.0; + return L * (a1 + a2 + a3 + a4 + a5 + a6 + a7); }; } - auto CantSlope = [A1, A2, A3, A4, A5, A6, A7, start = start_ * length_unit_, L = length_ * length_unit_, lu = length_unit_, length = length_](double t) { - 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; - auto a3 = A3.has_value() ? 3 * A3.value() * lu * std::pow(t, 2) / fabs(std::pow(A3.value() * lu, 5)) : 0.0; - auto a4 = A4.has_value() ? 4 * std::pow(t, 3) / std::pow(A4.value() * lu, 5) : 0.0; - auto a5 = A5.has_value() ? 5 * A5.value() * lu * std::pow(t, 4) / fabs(std::pow(A5.value() * lu, 7)) : 0.0; - auto a6 = A6.has_value() ? 6 * std::pow(t, 5) / std::pow(A6.value() * lu, 7) : 0.0; - auto a7 = A7.has_value() ? 7 * A7.value() * lu * std::pow(t, 6) / fabs(std::pow(A7.value() * lu, 9)) : 0.0; - return L * (a1 + a2 + a3 + a4 + a5 + a6 + a7); - }; - - set_cant_spiral_function(*Cant, CantSlope); + set_cant_spiral_function(*super, *slope, cant); } #ifdef SCHEMA_HAS_IfcSecondOrderPolynomialSpiral @@ -839,50 +908,52 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCurveSegment* inst) { Logger::Error(std::runtime_error(inst->ParentCurve()->declaration().name() + " not implemented"), inst); } - // Do a negative translation of the parent curve point relative to the start of the parent curve. - // This moves parent_curve_fn(u=0.0) to coordinate (0,0). - // This is done so the curve_segment_placement is applied relative to (0,0) - Eigen::Matrix4d remove_parent_curve_translation = Eigen::Matrix4d::Identity(); - remove_parent_curve_translation.col(3) = -1.0 * (*parent_curve_start_point).col(3); - remove_parent_curve_translation(3, 3) = 1.0; - - // Do a rotation so that the tangent of the parent curve is in the direction (1,0) - // Example: if the parent curve IfcLine is at a 30 degree clockwise angle, this does - // a 30 degree counter-clockwise rotation - // Clockwise rotation matrix = [cos(angle) -sin(angle)] - // [sin(angle) cos(angle)] - // - // Counter-clockwise rotation = [ cos(angle) sin(angle)] - // [-sin(angle) cos(angle)] - // - // That's just a sign flip in positions (0,1) and (1,0) - Eigen::Matrix4d remove_parent_curve_rotation = *parent_curve_start_point; - remove_parent_curve_rotation(0, 1) *= -1.0; - remove_parent_curve_rotation(1, 0) *= -1.0; - remove_parent_curve_rotation.col(3) = Eigen::Vector4d(0, 0, 0, 1); // remove the parent curve placement point - const auto& curve_segment_placement = cse.segment_placement(); std::function fn; if (segment_type == ST_CANT) { - // not sure if this is correct, but when applying my general formula to compute the 4x4 matrix of a point on curve segment, - // p = curve_segment_placement * remove_parent_curve_rotation * remove_parent_curve_translation * parent_curve_point, - // the directional vectors of curve_segment_placement are multiplied with the cant value (eg parent_curve_point(3,3)) and - // cause the resulting z value to be slightly off. My solution is to change the upper 3x3 of the curve_segment_placement - // matrix to identity. This results in correct cant values, but I think it messes up the resulting direction vectors - Eigen::Matrix4d c = Eigen::Matrix4d::Identity(); - c.col(3) = (*curve_segment_placement).col(3); - - fn = [c, remove_parent_curve_rotation, remove_parent_curve_translation, parent_curve_fn](double u) -> Eigen::Matrix4d { + fn = [curve_segment_placement, parent_curve_start_point, parent_curve_fn](double u) -> Eigen::Matrix4d { + // The parent curve function returns the cant rotation and superelevation for the parent curve. + // Subtract the parent_curve_start_point to get the incremental cant rotation and superelevation + // Add the incremental cant rotation and superelevation to curve_segment_placement to get the curve_segment_point Eigen::Matrix4d parent_curve_point = (*parent_curve_fn)(u); - Eigen::Matrix4d p = c * remove_parent_curve_rotation * remove_parent_curve_translation * parent_curve_point; - return p; + 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; }; } else { + // The parent curve function returns the 4x4 matrix for the parent curve. + // Subtract the parent curve start point (remove the translation and rotation) + // to get the incremental translation and rotation. Apply the incremental + // translation and rotation to the curve_segment_placement to get the curve_segment_point + + // Do a negative translation of the parent curve point relative to the start of the parent curve. + // This moves parent_curve_fn(u=0.0) to coordinate (0,0). + // This is done so the curve_segment_placement is applied relative to (0,0) + Eigen::Matrix4d remove_parent_curve_translation = Eigen::Matrix4d::Identity(); + remove_parent_curve_translation.col(3) = -1.0 * (*parent_curve_start_point).col(3); + remove_parent_curve_translation(3, 3) = 1.0; + + // Do a rotation so that the tangent of the parent curve is in the direction (1,0) + // Example: if the parent curve IfcLine is at a 30 degree clockwise angle, this does + // a 30 degree counter-clockwise rotation + // Clockwise rotation matrix = [cos(angle) -sin(angle)] + // [sin(angle) cos(angle)] + // + // Counter-clockwise rotation = [ cos(angle) sin(angle)] + // [-sin(angle) cos(angle)] + // + // That's just a sign flip in positions (0,1) and (1,0) + Eigen::Matrix4d remove_parent_curve_rotation = *parent_curve_start_point; + remove_parent_curve_rotation(0, 1) *= -1.0; + remove_parent_curve_rotation(1, 0) *= -1.0; + remove_parent_curve_rotation.col(3) = Eigen::Vector4d(0, 0, 0, 1); // remove the parent curve placement point + fn = [curve_segment_placement, remove_parent_curve_rotation, remove_parent_curve_translation, parent_curve_fn](double u) -> Eigen::Matrix4d { - auto parent_curve_point = (*parent_curve_fn)(u); - return (*curve_segment_placement) * remove_parent_curve_rotation * remove_parent_curve_translation * parent_curve_point; + 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; }; } @@ -890,7 +961,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCurveSegment* inst) { taxonomy::piecewise_function::spans_t spans; spans.emplace_back(fabs(length), fn); - auto pwf = taxonomy::make(0.0, spans,&settings_,inst); + auto pwf = taxonomy::make(0.0, spans,inst); return pwf; } diff --git a/src/ifcgeom/mapping/IfcEllipse.cpp b/src/ifcgeom/mapping/IfcEllipse.cpp index 1d1e70e303..8d79068aa4 100644 --- a/src/ifcgeom/mapping/IfcEllipse.cpp +++ b/src/ifcgeom/mapping/IfcEllipse.cpp @@ -41,8 +41,8 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcEllipse* inst) { // @todo is a copy necesary here or can this be done in place? auto m4_copy = *el->matrix; el->matrix->components() << - -m4_copy.components().col(1), - m4_copy.components().col(0), + m4_copy.components().col(1), + -m4_copy.components().col(0), m4_copy.components().col(2), m4_copy.components().col(3); std::swap(x, y); diff --git a/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp b/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp index d07e3e80fb..5608b01cdf 100644 --- a/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp +++ b/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp @@ -39,6 +39,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcEllipseProfileDef* inst) { #endif if (has_position) { m4 = taxonomy::cast(map(inst->Position())); + } else { + // matrix needs to be set on elementary curves. + m4 = taxonomy::make(); } if (ry > rx) { @@ -58,9 +61,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcEllipseProfileDef* inst) { auto el = taxonomy::make(); el->radius = rx; el->radius2 = ry; + el->matrix = m4; ed->basis = el; lp->children.push_back(ed); fc->children.push_back(lp); - fc->matrix = m4; return fc; } diff --git a/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp b/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp index 7267f61b2a..79632b9f01 100644 --- a/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp +++ b/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "mapping.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -34,6 +35,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcFixedReferenceSweptAreaSolid // @todo currently only the case is handled where directrix returns a piecewise_function if (auto pwf = taxonomy::dcast(dir)) { + piecewise_function_evaluator evaluator(pwf,&settings_); double start = 0; double end = pwf->length(); #ifdef SCHEMA_HAS_IfcDirectrixCurveSweptAreaSolid @@ -53,20 +55,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcFixedReferenceSweptAreaSolid } } #endif - auto curve_length = end - start; - auto param_type = settings_.get().get(); - auto param = settings_.get().get(); - size_t num_steps = 0; - if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { - // parameter is max step size - num_steps = (size_t) std::ceil(curve_length / param); - } else { - // parameter is minimum number of steps - num_steps = (size_t) std::ceil(param); - } - for (size_t i = 0; i <= num_steps; ++i) { - auto distalong = start + curve_length / num_steps * i; - auto m4 = pwf->evaluate(distalong); + auto evaluation_points = evaluator.evaluation_points(); + for (const auto& dist_along : evaluation_points) { + auto m4 = evaluator.evaluate(dist_along); /* std::stringstream ss; diff --git a/src/ifcgeom/mapping/IfcGradientCurve.cpp b/src/ifcgeom/mapping/IfcGradientCurve.cpp index 3c5b95635a..6217b81a0e 100644 --- a/src/ifcgeom/mapping/IfcGradientCurve.cpp +++ b/src/ifcgeom/mapping/IfcGradientCurve.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "mapping.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -55,7 +56,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcGradientCurve* inst) { double gradient_start = m(0, 3); // start of vertical (row 0, col 3) - "Distance Along" horizontal curve // create the vertical pwf - auto vertical = taxonomy::make(gradient_start, pwfs, &settings_); + auto vertical = taxonomy::make(gradient_start, pwfs); // Determine the valid domain of the PWF... the valid domain is where both // the base curve and gradient curves are defined @@ -69,11 +70,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcGradientCurve* inst) { } // define the callback function for the gradient curve - auto composition = [horizontal, vertical](double u)->Eigen::Matrix4d { + piecewise_function_evaluator horizontal_evaluator(horizontal, &settings_), vertical_evaluator(vertical, &settings_); + auto composition = [horizontal_evaluator, vertical_evaluator,start=vertical->start()](double u) -> Eigen::Matrix4d { // u is distance from start of gradient curve (vertical) // add vertical->start() to u to get distance from start of horizontal - auto xy = horizontal->evaluate(u + vertical->start()); - auto uz = vertical->evaluate(u); + 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 uz.col(1).swap(uz.col(2)); // uz is 2D in distance along - y plane, swap y and z so elevations become z @@ -86,7 +88,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcGradientCurve* inst) { taxonomy::piecewise_function::spans_t spans; spans.emplace_back(length, composition); - auto pwf = taxonomy::make(start, spans, &settings_, inst); + auto pwf = taxonomy::make(start, spans, inst); return pwf; } diff --git a/src/ifcgeom/mapping/IfcObjectPlacement.cpp b/src/ifcgeom/mapping/IfcObjectPlacement.cpp index f041c43145..3faf046335 100644 --- a/src/ifcgeom/mapping/IfcObjectPlacement.cpp +++ b/src/ifcgeom/mapping/IfcObjectPlacement.cpp @@ -65,16 +65,21 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcObjectPlacement* inst) { } } - taxonomy::ptr result; + taxonomy::matrix4::ptr result; if (!parent_placement_ignored && relative_to) { result = taxonomy::make( + // @nb this is a bit silly, in 0.7 we didn't have a recursive function + // but a while loop to apply the hierarchical placements, so after the + // loop we could apply the global offset. Since we have a recursive + // function now we need to undo the global offset when recursing. + offset_and_rotation_.inverse() * taxonomy::cast(map(relative_to))->ccomponents() * taxonomy::cast(map(transform))->ccomponents() ); } else { // The parent placement of the current is a placement for a type that is // being ignored (Site or Building) or it is the host element of an opening. - result = map(transform); + result = taxonomy::cast(map(transform)); } if (fallback) { @@ -84,10 +89,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcObjectPlacement* inst) { } } - return result; + result->components() = offset_and_rotation_ * result->ccomponents(); - // @todo - // m4->components() = offset_and_rotation_ * m4->components(); + return result; } /* diff --git a/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp b/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp index 7af8b42bd8..1ed4d05e8b 100644 --- a/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp +++ b/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp @@ -19,6 +19,7 @@ #include "mapping.h" #include "../profile_helper.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -144,11 +145,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOffsetCurveByDistances* inst offset_spans.emplace_back(l, fn); } - auto offsets = taxonomy::make(start,offset_spans,&settings_); + auto offsets = taxonomy::make(start,offset_spans); - auto composition = [pw_curve, offsets](double u) -> Eigen::Matrix4d { - auto p = pw_curve->evaluate(u); - auto offset = offsets->evaluate(u); + piecewise_function_evaluator pw_evaluator(pw_curve, &settings_), offsets_evaluator(offsets, &settings_); + auto composition = [pw_evaluator, offsets_evaluator](double u) -> Eigen::Matrix4d { + auto p = pw_evaluator.evaluate(u); + auto offset = offsets_evaluator.evaluate(u); Eigen::Matrix4d m = p * offset; return m; }; @@ -157,7 +159,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOffsetCurveByDistances* inst // this may change depending on decisions in the bSI-IF taxonomy::piecewise_function::spans_t spans; spans.emplace_back(basis_curve_length, composition); - auto pwf = taxonomy::make(start,spans,&settings_,inst); + auto pwf = taxonomy::make(start,spans,inst); return pwf; } diff --git a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp index 601765e1a3..6097344eb3 100644 --- a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp +++ b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp @@ -19,6 +19,7 @@ #include "mapping.h" #include "../profile_helper.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -31,7 +32,8 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i //auto item = map(basis_curve); //auto pw_curve = ifcopenshell::geometry::piecewise_from_item(item); auto pw_curve = taxonomy::dcast(map(inst->BasisCurve())); - auto m = pw_curve->evaluate(u); + piecewise_function_evaluator evaluator(pw_curve,&settings_); + auto m = evaluator.evaluate(u); auto o = m.col(3).head<3>(); auto z = m.col(2).head<3>(); diff --git a/src/ifcgeom/mapping/IfcRectangularPyramid.cpp b/src/ifcgeom/mapping/IfcRectangularPyramid.cpp index 70ff3e9a9d..ab22069991 100644 --- a/src/ifcgeom/mapping/IfcRectangularPyramid.cpp +++ b/src/ifcgeom/mapping/IfcRectangularPyramid.cpp @@ -21,6 +21,8 @@ #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; +#include "../profile_helper.h" + taxonomy::ptr mapping::map_impl(const IfcSchema::IfcRectangularPyramid* inst) { const double dx = inst->XLength() * length_unit_; const double dy = inst->YLength() * length_unit_; @@ -33,96 +35,77 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcRectangularPyramid* inst) { // Base { auto face = taxonomy::make(); - auto loop = taxonomy::make(); - face->children.push_back(loop); - loop->external = true; shell->children.push_back(face); - std::array points{ + std::vector points{ taxonomy::make(0, 0, 0), - taxonomy::make(dx, 0, 0), + taxonomy::make(0, dy, 0), taxonomy::make(dx, dy, 0), - taxonomy::make(0, dy, 0) + taxonomy::make(dx, 0, 0), }; - loop->children.push_back(taxonomy::make(points[0], points[1])); - loop->children.push_back(taxonomy::make(points[1], points[2])); - loop->children.push_back(taxonomy::make(points[2], points[3])); - loop->children.push_back(taxonomy::make(points[3], points[0])); + points.push_back(points.front()); + face->children.push_back(polygon_from_points(points)); } // Lateral faces { auto face = taxonomy::make(); - auto loop = taxonomy::make(); - face->children.push_back(loop); - loop->external = true; shell->children.push_back(face); - std::array points{ + std::vector points{ taxonomy::make(0, 0, 0), taxonomy::make(0, dy, 0), taxonomy::make(0.5*dx, 0.5*dy, dz) }; - loop->children.push_back(taxonomy::make(points[0], points[1])); - loop->children.push_back(taxonomy::make(points[1], points[2])); - loop->children.push_back(taxonomy::make(points[2], points[0])); + points.push_back(points.front()); + face->children.push_back(polygon_from_points(points)); } { auto face = taxonomy::make(); - auto loop = taxonomy::make(); - face->children.push_back(loop); - loop->external = true; shell->children.push_back(face); - std::array points{ + std::vector points{ taxonomy::make(0, dy, 0), taxonomy::make(dx, dy, 0), taxonomy::make(0.5*dx, 0.5*dy, dz) }; - loop->children.push_back(taxonomy::make(points[0], points[1])); - loop->children.push_back(taxonomy::make(points[1], points[2])); - loop->children.push_back(taxonomy::make(points[2], points[0])); + points.push_back(points.front()); + face->children.push_back(polygon_from_points(points)); } { auto face = taxonomy::make(); - auto loop = taxonomy::make(); - face->children.push_back(loop); - loop->external = true; shell->children.push_back(face); - std::array points{ + std::vector points{ taxonomy::make(dx, dy, 0), taxonomy::make(dx, 0, 0), taxonomy::make(0.5*dx, 0.5*dy, dz) }; - loop->children.push_back(taxonomy::make(points[0], points[1])); - loop->children.push_back(taxonomy::make(points[1], points[2])); - loop->children.push_back(taxonomy::make(points[2], points[0])); + points.push_back(points.front()); + face->children.push_back(polygon_from_points(points)); } { auto face = taxonomy::make(); - auto loop = taxonomy::make(); - face->children.push_back(loop); - loop->external = true; shell->children.push_back(face); - std::array points{ + std::vector points{ taxonomy::make(dx, 0, 0), taxonomy::make(0, 0, 0), taxonomy::make(0.5*dx, 0.5*dy, dz) }; - loop->children.push_back(taxonomy::make(points[0], points[1])); - loop->children.push_back(taxonomy::make(points[1], points[2])); - loop->children.push_back(taxonomy::make(points[2], points[0])); + points.push_back(points.front()); + face->children.push_back(polygon_from_points(points)); } + solid->matrix = taxonomy::cast(map(inst->Position())); + return solid; } diff --git a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp index 74e6e4cd89..14ee9899ad 100644 --- a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp @@ -22,28 +22,10 @@ using namespace ifcopenshell::geometry; #include "../../ifcgeom/profile_helper.h" - -#include +#include "../../ifcgeom/infra_sweep_helper.h" #ifdef SCHEMA_HAS_IfcSectionedSolidHorizontal -namespace { - // std::lerp when upgrading to C++ 20 - template - T lerp(const T& a, const T& b, double t) { - return a + t * (b - a); - } - - struct cross_section { - double dist_along; - taxonomy::face::ptr section_geometry; - Eigen::Vector3d offset; - - bool operator <(const cross_section& other) const { - return dist_along < other.dist_along; - } - }; -} taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* inst) { std::vector cross_sections; @@ -105,162 +87,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in } } - std::sort(cross_sections.begin(), cross_sections.end()); - - auto loft = taxonomy::make(); - // @todo intialize as default - loft->axis = nullptr; - - // @todo currently only the case is handled where directrix returns a piecewise_function - // @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a piecewise function - if (pwf) { - double start = std::max(0., cross_sections.front().dist_along); - double end = std::min(pwf->length(), cross_sections.back().dist_along); - - if (end - start < 1.e-9) { - Logger::Warning("Empty sweep domain with start at " + std::to_string(cross_sections.front().dist_along) + " end at " + std::to_string(cross_sections.back().dist_along) + " and curve domain length " + std::to_string(pwf->length()), inst); - return nullptr; - } - - auto curve_length = end - start; - auto param_type = settings_.get().get(); - auto param = settings_.get().get(); - size_t num_steps = 0; - if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { - // parameter is max step size - num_steps = (size_t) std::ceil(curve_length / param); - } else { - // parameter is minimum number of steps - num_steps = (size_t) std::ceil(param); - } - std::vector longitudes; - for (auto& x : cross_sections) { - longitudes.push_back(x.dist_along); - } - longitudes.push_back(std::numeric_limits::infinity()); - auto profile_index = longitudes.begin(); - for (size_t i = 0; i <= num_steps; ++i) { - auto dist_along = start + curve_length / num_steps * i; - while (dist_along > *(profile_index+1)) { - profile_index++; - if (profile_index == longitudes.end()) { - // @todo handle this? - } - } - - 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; - - taxonomy::face::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. - bool should_interpolate = - (profile_index + 1 < longitudes.end()) && - (relative_dist_along >= 1.e-9 || offset_a.cwiseAbs().maxCoeff() > 0.); - - if (should_interpolate) { - taxonomy::face::ptr profile_b; - Eigen::Vector3d offset_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; - } else { - profile_b = profile_a; - offset_b = offset_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.); - - if (should_interpolate2) { - if (profile_a->children.size() != profile_b->children.size()) { - Logger::Warning("Mismatching number of face boundaries: " + - std::to_string(profile_a->children.size()) + " vs " + - std::to_string(profile_b->children.size()), - inst - ); - return nullptr; - } - interpolated = taxonomy::make(); - // @todo should_interpolate should also be informed based by different face matrices. - if (profile_a->matrix || profile_b->matrix) { - interpolated->matrix = taxonomy::make(); - Eigen::Matrix4d m4a = Eigen::Matrix4d::Identity(); - Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); - if (profile_a->matrix) { - m4a = profile_a->matrix->ccomponents(); - } - if (profile_b->matrix) { - m4b = profile_b->matrix->ccomponents(); - } - interpolated->matrix->components() = lerp(m4a, m4b, relative_dist_along); - } - auto interpolated_offset = lerp(offset_a, offset_b, relative_dist_along); - taxonomy::loop::ptr w1, w2; - taxonomy::edge::ptr e1, e2; - for (auto tmp_ : boost::combine(profile_a->children, profile_b->children)) { - boost::tie(w1, w2) = tmp_; - if (w1->children.size() != w2->children.size()) { - Logger::Warning("Mismatching number of edges for face boundary: " + - std::to_string(w1->children.size()) + " vs " + - std::to_string(w2->children.size()), - inst - ); - return nullptr; - } - std::vector points; - for (auto tmp__ : boost::combine(w1->children, w2->children)) { - boost::tie(e1, e2) = tmp__; - auto& p1 = boost::get(e1->start); - auto& p2 = boost::get(e2->start); - - auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); - points.push_back(taxonomy::make(p3)); - } - if (!points.empty()) { - // close polygon by referencing first point - // @todo add a closed=true|false to polygon_from_points()? - points.push_back(points.front()); - } - interpolated->children.push_back(polygon_from_points(points)); - } - } - } - - auto m4 = pwf->evaluate(dist_along); - /* { - std::wcout << "#" << pwf->instance->data().id() << " " << dist_along << ": " << m4.col(3).row(2).value() << std::endl; - }*/ - - 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(); - m4b.col(3).head<3>() = m4.col(3).head<3>(); - - if (interpolated) { - loft->children.push_back(interpolated); - } else { - loft->children.push_back(taxonomy::face::ptr(profile_a->clone_())); - if (profile_a->matrix) { - loft->children.back()->matrix = taxonomy::matrix4::ptr(profile_a->matrix->clone_()); - } - } - if (!loft->children.back()->matrix) { - // @todo should this not be initialized by default? matrix4 already has a 'lazy identity' mechanism. - loft->children.back()->matrix = taxonomy::make(); - } - auto m = (m4b * loft->children.back()->matrix->ccomponents()).eval(); - loft->children.back()->matrix->components() = m; - } - } - - return loft; + return make_loft(settings_, inst, pwf, cross_sections); } #endif diff --git a/src/ifcgeom/mapping/IfcSectionedSurface.cpp b/src/ifcgeom/mapping/IfcSectionedSurface.cpp new file mode 100644 index 0000000000..3edcde7946 --- /dev/null +++ b/src/ifcgeom/mapping/IfcSectionedSurface.cpp @@ -0,0 +1,93 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 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 * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "mapping.h" +#define mapping POSTFIX_SCHEMA(mapping) +using namespace ifcopenshell::geometry; + +#include "../../ifcgeom/profile_helper.h" +#include "../../ifcgeom/infra_sweep_helper.h" + +#ifdef SCHEMA_HAS_IfcSectionedSurface + + +taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { + std::vector cross_sections; + + auto dir = map(inst->Directrix()); + auto pwf = taxonomy::dcast(dir); + if (!pwf) { + // Only implement on alignment curves + Logger::Warning("IfcSectionedSurface is only implemented for piecewise function Directrix curves", inst); + return nullptr; + } + + { + auto css = inst->CrossSections(); + auto csps = inst->CrossSectionPositions(); + std::vector faces; + + // The PointByDistanceExpressesions 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 longitudes; + + for (auto& cs : *css) { + faces.push_back(std::move(taxonomy::cast(map(cs)))); + } +#ifdef SCHEMA_HAS_IfcPointByDistanceExpression + for (auto& csp : *csps) { + auto pbde = csp->Location()->as(true); + + longitudes.push_back(*pbde->DistanceAlong()->as(true) * length_unit_); + + // Corresponds to the profile X, Y directions (hopefully). + Eigen::Vector3d po( + pbde->OffsetLateral().get_value_or(0.), + // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane + pbde->OffsetVertical().get_value_or(0.), + 0. + ); + + profile_offsets.push_back(po); + } +#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; + } + if (faces.size() < 2) { + Logger::Warning("Expected at least two cross sections, but got " + std::to_string(faces.size()), inst); + return nullptr; + } + + for (size_t i = 0; i < faces.size(); ++i) { + cross_sections.push_back({ longitudes[i], faces[i], profile_offsets[i] }); + } + } + + return make_loft(settings_, inst, pwf, cross_sections); +} + +#endif diff --git a/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp b/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp index caf8104c65..d1ab88fbe5 100644 --- a/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp +++ b/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp @@ -21,6 +21,8 @@ #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; +#include "../piecewise_function_evaluator.h" + #ifdef SCHEMA_HAS_IfcSegmentedReferenceCurve taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* inst) { @@ -53,7 +55,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* ins const Eigen::Matrix4d& m = p->ccomponents(); double cant_start = m(0, 3); // start of cant curve - auto cant = taxonomy::make(cant_start,pwfs,&settings_); + auto cant = taxonomy::make(cant_start,pwfs); // Determine the valid domain of the PWF... the valid domain is where // horizontal, gradient and cant curves are defined @@ -68,24 +70,45 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* ins } // define the callback function for the segmented reference curve - auto composition = [gradient, cant](double u)->Eigen::Matrix4d { + piecewise_function_evaluator gradient_evaluator(gradient, &settings_), cant_evaluator(cant, &settings_); + auto composition = [gradient_evaluator, cant_evaluator, start = cant->start()](double u) -> Eigen::Matrix4d { // u is distance from start of cant curve // add cant->start() to u to get the distance from start of gradient curve - auto g = gradient->evaluate(u+cant->start()); - auto c = cant->evaluate(u); + auto g = gradient_evaluator.evaluate(u + start); + auto c = cant_evaluator.evaluate(u); - c.col(3)(0) = 0.0; // x is distance along. zero it out so it doesn't add to the x from gradient curve - c.col(1).swap(c.col(2)); // c is 2D in distance along - y plane, swap y and z so elevations become z - c.row(1).swap(c.row(2)); + // 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 + // + // However, the coordinate points don't need to have the rotations + // of g applied. Save off the x,y,z and cant values + auto x = g(0, 3); + auto y = g(1, 3); + auto z = g(2, 3); + auto s = c(1, 3); // superelevation + + // change column 3 to (0,0,0,1) + Eigen::Vector4d p(0, 0, 0, 1); + g.col(3) = p; + c.col(3) = p; + + // multiply g and c to get the axes in the correct orientation + Eigen::Matrix4d m = g * c; + + // reinstate the values for x and y. + // z is the gradient curve z value plus the superelevation + // that comes from the cant. + m(0, 3) = x; + m(1, 3) = y; + m(2, 3) = z + s; - Eigen::Matrix4d m; - m = g * c; return m; }; taxonomy::piecewise_function::spans_t spans; spans.emplace_back(length, composition); - auto pwf = taxonomy::make(start, spans, &settings_, inst); + auto pwf = taxonomy::make(start, spans, inst); return pwf; } diff --git a/src/ifcgeom/mapping/mapping.cpp b/src/ifcgeom/mapping/mapping.cpp index b755c8df05..7c0235cfa3 100644 --- a/src/ifcgeom/mapping/mapping.cpp +++ b/src/ifcgeom/mapping/mapping.cpp @@ -165,10 +165,8 @@ aggregate_of_instance::ptr mapping::find_openings(const IfcUtil::IfcBaseEntity* return openings; } - auto product = inst->as(); - - if (product->as() && !product->as()) { - const IfcSchema::IfcElement* element = product->as(); + if (inst->as() && !inst->as()) { + const IfcSchema::IfcElement* element = inst->as(); auto rels = element->HasOpenings(); for (auto& rel : *rels) { openings->push(rel->RelatedOpeningElement()); @@ -176,20 +174,22 @@ aggregate_of_instance::ptr mapping::find_openings(const IfcUtil::IfcBaseEntity* } // Is the IfcElement a decomposition of an IfcElement with any IfcOpeningElements? - const IfcSchema::IfcObjectDefinition* obdef = product->as(); - for (;;) { - auto decomposes = obdef->Decomposes()->generalize(); - if (decomposes->size() != 1) break; - IfcSchema::IfcObjectDefinition* rel_obdef = (*decomposes->begin())->as()->RelatingObject(); - if (rel_obdef->as() && !rel_obdef->as()) { - IfcSchema::IfcElement* element = rel_obdef->as(); - auto rels = element->HasOpenings(); - for (auto& rel : *rels) { - openings->push(rel->RelatedOpeningElement()); + const IfcSchema::IfcObjectDefinition* obdef = inst->as(); + if (obdef != nullptr) { + for (;;) { + auto decomposes = obdef->Decomposes()->generalize(); + if (decomposes->size() != 1) break; + IfcSchema::IfcObjectDefinition* rel_obdef = (*decomposes->begin())->as()->RelatingObject(); + if (rel_obdef->as() && !rel_obdef->as()) { + IfcSchema::IfcElement* element = rel_obdef->as(); + auto rels = element->HasOpenings(); + for (auto& rel : *rels) { + openings->push(rel->RelatedOpeningElement()); + } } - } - obdef = rel_obdef; + obdef = rel_obdef; + } } return openings; @@ -810,6 +810,28 @@ void mapping::initialize_units_() { if (settings_.get().get()) { placement_rel_to_type_ = file_->schema()->declaration_by_name("IfcSite"); } + + // Translation is applied first, then rotation. + if (settings_.get().has()) { + auto vs = settings_.get().get(); + if (vs.size() == 3) { + offset_and_rotation_ *= Eigen::Affine3d(Eigen::Translation3d(vs[0], vs[1], vs[2])).matrix(); + } else { + Logger::Error("Expected 3 values for model-offset setting"); + } + } + + 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(); + Eigen::Matrix4d m4 = Eigen::Matrix4d::Identity(); + m4 << m3; + offset_and_rotation_ *= m4; + } else { + Logger::Error("Expected 4 values for model-rotation setting"); + } + } } void mapping::initialize_settings() { diff --git a/src/ifcgeom/mapping/mapping.h b/src/ifcgeom/mapping/mapping.h index ace92a2276..770bc3aa19 100644 --- a/src/ifcgeom/mapping/mapping.h +++ b/src/ifcgeom/mapping/mapping.h @@ -30,6 +30,8 @@ namespace geometry { const IfcParse::declaration* placement_rel_to_type_; const IfcUtil::IfcBaseEntity* placement_rel_to_instance_; + + Eigen::Matrix4d offset_and_rotation_ = Eigen::Matrix4d::Identity(); void initialize_units_(); void addRepresentationsFromContextIds(IfcSchema::IfcRepresentation::list::ptr&); @@ -52,7 +54,7 @@ namespace geometry { try { if (inst->as() && !inst->as() && /* @todo */ - (item->kind() == taxonomy::SOLID || item->kind() == taxonomy::SHELL || item->kind() == taxonomy::COLLECTION || item->kind() == taxonomy::EXTRUSION || item->kind() == taxonomy::LOFT || item->kind() == taxonomy::BOOLEAN_RESULT || item->kind() == taxonomy::REVOLVE || item->kind() == taxonomy::SWEEP_ALONG_CURVE) + (item->kind() == taxonomy::SOLID || item->kind() == taxonomy::SHELL || item->kind() == taxonomy::COLLECTION || item->kind() == taxonomy::EXTRUSION || item->kind() == taxonomy::LOFT || item->kind() == taxonomy::BOOLEAN_RESULT || item->kind() == taxonomy::REVOLVE || item->kind() == taxonomy::SWEEP_ALONG_CURVE || item->kind() == taxonomy::FACE) ) { auto style = find_style(inst->as()); if (style) { diff --git a/src/ifcgeom/mapping/mapping.i b/src/ifcgeom/mapping/mapping.i index c6ed7a11f9..51e001f05d 100644 --- a/src/ifcgeom/mapping/mapping.i +++ b/src/ifcgeom/mapping/mapping.i @@ -135,6 +135,9 @@ BIND(IfcFixedReferenceSweptAreaSolid) #ifdef SCHEMA_HAS_IfcSectionedSolidHorizontal BIND(IfcSectionedSolidHorizontal) #endif +#ifdef SCHEMA_HAS_IfcSectionedSurface +BIND(IfcSectionedSurface) +#endif BIND(IfcCircle); BIND(IfcEllipse); diff --git a/src/ifcgeom/piecewise_function_evaluator.cpp b/src/ifcgeom/piecewise_function_evaluator.cpp new file mode 100644 index 0000000000..b85b165f21 --- /dev/null +++ b/src/ifcgeom/piecewise_function_evaluator.cpp @@ -0,0 +1,103 @@ +#include "piecewise_function_evaluator.h" +#include "profile_helper.h" + +using namespace ifcopenshell::geometry; + + +piecewise_function_evaluator::piecewise_function_evaluator(taxonomy::piecewise_function::const_ptr pwf, const ifcopenshell::geometry::Settings* settings) : pwf_(pwf) { + if (settings) { + settings_ = *settings; + } +} + +std::vector piecewise_function_evaluator::evaluation_points() const { + if (!eval_points_.has_value()) { + double curve_length = pwf_->length(); + + auto param_type = settings_.get().get(); + auto param = settings_.get().get(); + unsigned num_steps = 0; + if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { + // parameter is max step size + num_steps = (unsigned)std::ceil(curve_length / param); + } else { + // parameter is minimum number of steps + num_steps = (unsigned)std::ceil(param); + } + + eval_points_ = evaluation_points(pwf_->start(), pwf_->start() + curve_length, num_steps); + } + return *eval_points_; +} + +std::vector piecewise_function_evaluator::evaluation_points(double ustart, double uend, unsigned nsteps) const { + double curve_length = pwf_->length(); + ustart = std::max(pwf_->start(), ustart); + uend = std::min(uend, pwf_->start() + curve_length); + + nsteps = std::max(1u, nsteps); // never have fewer than 1 step + + auto resolution = (uend - ustart) / nsteps; + + std::vector u_values; + u_values.reserve(nsteps); + + for (unsigned i = 0; i <= nsteps; ++i) { + auto u = resolution * i + ustart; + u_values.push_back(u); + } + + return u_values; +} + +taxonomy::item::ptr piecewise_function_evaluator::evaluate() const { + return evaluate(evaluation_points()); +} + +taxonomy::item::ptr piecewise_function_evaluator::evaluate(double ustart, double uend, unsigned nsteps) const { + return evaluate(evaluation_points(ustart, uend, nsteps)); +} + +Eigen::Matrix4d piecewise_function_evaluator::evaluate(double u) const { + // assume monotonic evaluation and store last evaluated segment + if (current_span_fn_ == nullptr || (u < current_span_start_ || current_span_end_ < u)) { + // there isn't a current span or u is outside the range of the current span + // get a new "current span" + std::tie(current_span_start_, current_span_end_, current_span_fn_) = get_span(u); + } + + u -= current_span_start_; // make u relative to start of span + return (*current_span_fn_)(u); +} + +taxonomy::item::ptr piecewise_function_evaluator::evaluate(const std::vector& dist) const { + std::vector polygon; + polygon.reserve(dist.size()); + for (auto& u : dist) { + Eigen::Matrix4d m = evaluate(u); + polygon.push_back(taxonomy::make(m(0, 3), m(1, 3), m(2, 3))); + } + + return polygon_from_points(polygon); +} + +std::tuple*> piecewise_function_evaluator::get_span(double u) const { + // force u to be within bounds of the curve + double s = pwf_->start(); + double e = pwf_->end(); + u = std::max(s, u); + u = std::min(u, e); + + double span_start = s; + for (auto& [length, fn] : pwf_->spans()) { + double span_end = span_start + length; + auto tolerance = settings_.get().get(); + if (span_start <= u && u < span_end + tolerance) { + return {span_start, span_end, &fn}; + } + span_start += length; + } + + Logger::Error("piecewise_function_impl::get_span span not found."); + return {0, 0, nullptr}; +} diff --git a/src/ifcgeom/piecewise_function_evaluator.h b/src/ifcgeom/piecewise_function_evaluator.h new file mode 100644 index 0000000000..3d91547268 --- /dev/null +++ b/src/ifcgeom/piecewise_function_evaluator.h @@ -0,0 +1,58 @@ +#ifndef ITERATOR_PWF_EVALUATOR_H +#define ITERATOR_PWF_EVALUATOR_H + +#include "../ifcgeom/taxonomy.h" + +#include + +namespace ifcopenshell { namespace geometry { + +/// @brief utility class to evaluate piecewise_function objects +class piecewise_function_evaluator { + public: + piecewise_function_evaluator(taxonomy::piecewise_function::const_ptr pwf, const ifcopenshell::geometry::Settings* settings=nullptr); + + /// @brief returns a vector of "distance along" points where the evaluate function computes loop points + std::vector evaluation_points() const; + + /// @brief returns a vector of "distance along" points between ustart and uend + /// @param ustart starting location + /// @param uend ending location + /// @param nsteps number of steps to evaluate + std::vector evaluation_points(double ustart, double uend, unsigned nsteps) const; + + /// @brief evaluates the piecewise function between start and end + /// evaluation point step size is taken from the settings object + taxonomy::item::ptr evaluate() const; + + /// @brief evaluates the piecewise function between ustart and uend + /// if ustart and uend are out of range, the range of values evaluated + /// are constrained to start_ and start_+length_ + /// @param ustart starting location + /// @param uend ending location + /// @param nsteps number of steps to evaluate + /// @return taxonomy::loop::ptr + taxonomy::item::ptr evaluate(double ustart, double uend, unsigned nsteps) const; + + /// @brief evaluates the piecewise function at u + /// @param u u is constrained to be between start_ and start_+length + /// @return 4x4 placement matrix + Eigen::Matrix4d evaluate(double u) const; + + private: + taxonomy::item::ptr evaluate(const std::vector& dist) const; + std::tuple*> get_span(double u) const; + + taxonomy::piecewise_function::const_ptr pwf_; + + ifcopenshell::geometry::Settings settings_; + + mutable double current_span_start_ = 0; + mutable double current_span_end_ = 0; + mutable const std::function* current_span_fn_ = nullptr; + mutable boost::optional> eval_points_; +}; + +}} + +#endif \ No newline at end of file diff --git a/src/ifcgeom/piecewise_function_impl.cpp b/src/ifcgeom/piecewise_function_impl.cpp index ecd2762057..6fa29fb649 100644 --- a/src/ifcgeom/piecewise_function_impl.cpp +++ b/src/ifcgeom/piecewise_function_impl.cpp @@ -7,97 +7,40 @@ namespace geometry { namespace taxonomy { -std::vector ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluation_points() const { - if (!eval_points_.has_value()) { - double curve_length = length(); +piecewise_function_impl::piecewise_function_impl(double start, const spans_t& s) : start_(start), spans_(s) { +} - auto param_type = settings_ ? settings_->get().get() : ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE; - auto param = settings_ ? settings_->get().get() : 0.5; - unsigned num_steps = 0; - if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { - // parameter is max step size - num_steps = (unsigned)std::ceil(curve_length / param); - } else { - // parameter is minimum number of steps - num_steps = (unsigned)std::ceil(param); - } - - eval_points_ = evaluation_points(start_, start_ + curve_length, num_steps); +piecewise_function_impl::piecewise_function_impl(double start, const std::vector& pwfs) : start_(start) { + for (auto& pwf : pwfs) { + spans_.insert(spans_.end(), pwf->spans().begin(), pwf->spans().end()); } - return *eval_points_; } -std::vector ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluation_points(double ustart, double uend, unsigned nsteps) const { - double curve_length = length(); - ustart = std::max(start_, ustart); - uend = std::min(uend, start_ + curve_length); +const piecewise_function_impl::spans_t& piecewise_function_impl::spans() const { return spans_; } - nsteps = std::max(1u, nsteps); // never have fewer than 1 step +bool piecewise_function_impl::is_empty() const { return spans_.empty(); } - auto resolution = (uend - ustart) / nsteps; - - std::vector u_values; - u_values.reserve(nsteps); - - for (unsigned i = 0; i <= nsteps; ++i) { - auto u = resolution * i + ustart; - u_values.push_back(u); - } - - return u_values; +double piecewise_function_impl::start() const { + return start_; } -ifcopenshell::geometry::taxonomy::item::ptr ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate() const { - return evaluate(evaluation_points()); +double piecewise_function_impl::end() const { + return start_ + length(); } -item::ptr ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate(double ustart, double uend, unsigned nsteps) const { - return evaluate(evaluation_points(ustart, uend, nsteps)); +double piecewise_function_impl::length() const { + return std::accumulate(spans_.begin(), spans_.end(), 0.0, [](const auto& v, const auto& s) { return v + s.first; }); + + // this is a secondary option where we only compute length once and cache it. + // mutex is needed to prevent interruption of the accumulation if there is multi-threading + // skipping this detail for now and just adding up the span lengths every time + //if (!length_.has_value()) { + // length_ = std::accumulate(spans_.begin(), spans_.end(), 0.0, [](const auto& v, const auto& s) { return v + s.first; }); + //} + //return *length_; } -item::ptr ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate(const std::vector& dist) const { - std::vector polygon; - polygon.reserve(dist.size()); - for (auto& u : dist) { - Eigen::Matrix4d m = evaluate(u); - polygon.push_back(taxonomy::make(m.col(3)(0), m.col(3)(1), m.col(3)(2))); - } - - return polygon_from_points(polygon); -} - -Eigen::Matrix4d ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate(double u) const { - // assume monotonic evaluation and store last evaluated segment - if (current_span_fn_ == nullptr || (u < current_span_start_ || current_span_end_ < u)) { - // there isn't a current span or u is outside the range of the current span - // get a new "current span" - std::tie(current_span_start_, current_span_end_, current_span_fn_) = get_span(u); - } - - u -= current_span_start_; // make u relative to start of span - return (*current_span_fn_)(u); -} - -std::tuple*> ifcopenshell::geometry::taxonomy::piecewise_function_impl::get_span(double u) const { - // force u to be within bounds of the curve - double s = start(); - double e = end(); - u = std::max(s, u); - u = std::min(u, e); - - double span_start = s; - for (auto& [length, fn] : spans_) { - double span_end = span_start + length; - auto tolerance = settings_ ? settings_->get().get() : 0.001; - if (span_start <= u && u < span_end + tolerance) { - return {span_start, span_end, &fn}; - } - span_start += length; - } - - Logger::Error("piecewise_function_impl::get_span span not found."); - return {0, 0, nullptr}; -} +piecewise_function_impl* piecewise_function_impl::clone_() const { return new piecewise_function_impl(*this); } } // namespace taxonomy diff --git a/src/ifcgeom/piecewise_function_impl.h b/src/ifcgeom/piecewise_function_impl.h index 0c2f306ac5..daf8636feb 100644 --- a/src/ifcgeom/piecewise_function_impl.h +++ b/src/ifcgeom/piecewise_function_impl.h @@ -12,79 +12,23 @@ namespace taxonomy { struct piecewise_function_impl { using spans_t = std::vector>>; - piecewise_function_impl(double start, const spans_t& s, ifcopenshell::geometry::Settings* settings = nullptr) : start_(start), - settings_(settings), - spans_(s){}; - piecewise_function_impl(double start, const std::vector& pwfs, ifcopenshell::geometry::Settings* settings = nullptr) : start_(start), - settings_(settings) { - for (auto& pwf : pwfs) { - spans_.insert(spans_.end(), pwf->spans().begin(), pwf->spans().end()); - } - }; + piecewise_function_impl(double start, const spans_t& s); + piecewise_function_impl(double start, const std::vector& pwfs); piecewise_function_impl(piecewise_function_impl&&) = default; piecewise_function_impl(const piecewise_function_impl&) = default; - const ifcopenshell::geometry::Settings* settings_ = nullptr; - - const spans_t& spans() const { return spans_; } - - bool is_empty() const { return spans_.empty(); } - - double start() const { - return start_; - } - - double end() const { - return start_ + length(); - } - - double length() const { - if (!length_.has_value()) { - length_ = std::accumulate(spans_.begin(), spans_.end(), 0.0, [](const auto& v, const auto& s) { return v + s.first; }); - } - return *length_; - } - - piecewise_function_impl* clone_() const { return new piecewise_function_impl(*this); } - - /// @brief returns a vector of "distance along" points where the evaluate function computes loop points - std::vector evaluation_points() const; - - /// @brief returns a vector of "distance along" points between ustart and uend - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - std::vector evaluation_points(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function between start and end - /// evaluation point step size is taken from the settings object - item::ptr evaluate() const; - - /// @brief evaluates the piecewise function between ustart and uend - /// if ustart and uend are out of range, the range of values evaluated - /// are constrained to start_ and start_+length_ - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - /// @return taxonomy::loop::ptr - item::ptr evaluate(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function at u - /// @param u u is constrained to be between start_ and start_+length - /// @return 4x4 placement matrix - Eigen::Matrix4d evaluate(double u) const; + const spans_t& spans() const; + bool is_empty() const; + double start() const; + double end() const; + double length() const; + piecewise_function_impl* clone_() const; private: - item::ptr evaluate(const std::vector& dist) const; - std::tuple*> get_span(double u) const; double start_ = 0.0; // starting value of the pwf spans_t spans_; - mutable double current_span_start_ = 0; - mutable double current_span_end_ = 0; - mutable const std::function* current_span_fn_ = nullptr; - mutable boost::optional length_; - mutable boost::optional> eval_points_; + //mutable boost::optional length_; // used for length() method }; } // namespace taxonomy diff --git a/src/ifcgeom/taxonomy.cpp b/src/ifcgeom/taxonomy.cpp index 9b4f5043aa..6f5e6249c7 100644 --- a/src/ifcgeom/taxonomy.cpp +++ b/src/ifcgeom/taxonomy.cpp @@ -313,7 +313,7 @@ namespace { } bool compare(const loft& a, const loft& b) { - return compare_collection(a, b); + return compare_collection(a, b); } bool compare(const collection& a, const collection& b) { @@ -464,12 +464,12 @@ ifcopenshell::geometry::taxonomy::solid::ptr ifcopenshell::geometry::create_box( } /////////////////// -piecewise_function::piecewise_function(double start, const spans_t& s, ifcopenshell::geometry::Settings* settings, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { - impl_ = new piecewise_function_impl(start, s, settings); +piecewise_function::piecewise_function(double start, const spans_t& s, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { + impl_ = new piecewise_function_impl(start, s); } -piecewise_function::piecewise_function(double start, const std::vector& pwfs, ifcopenshell::geometry::Settings* settings, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { - impl_ = new piecewise_function_impl(start, pwfs, settings); +piecewise_function::piecewise_function(double start, const std::vector& pwfs, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { + impl_ = new piecewise_function_impl(start, pwfs); }; piecewise_function::piecewise_function(const piecewise_function& other) : implicit_item(other) { @@ -486,11 +486,6 @@ double piecewise_function::start() const { return impl_->start(); } double piecewise_function::end() const { return impl_->end(); } double piecewise_function::length() const { return impl_->length(); } -std::vector piecewise_function::evaluation_points() const { return impl_->evaluation_points(); } -std::vector piecewise_function::evaluation_points(double ustart, double uend, unsigned nsteps) const { return impl_->evaluation_points(ustart, uend, nsteps); } -item::ptr piecewise_function::evaluate() const { return impl_->evaluate(); } -item::ptr piecewise_function::evaluate(double ustart, double uend, unsigned nsteps) const { return impl_->evaluate(ustart, uend, nsteps); } -Eigen::Matrix4d piecewise_function::evaluate(double u) const { return impl_->evaluate(u); } ifcopenshell::geometry::taxonomy::collection::ptr ifcopenshell::geometry::flatten(const taxonomy::collection::ptr& deep) { auto flat = make(); @@ -599,3 +594,160 @@ void ifcopenshell::geometry::taxonomy::extrusion::print(std::ostream& o, int ind direction->print(o, indent + 4); basis->print(o, indent + 4); } + +boost::optional ifcopenshell::geometry::taxonomy::loop_to_face_upgrade_impl(ptr item) { + boost::optional face_; + auto loop_ = dcast(item); + if (loop_) { + loop_->external = true; + + face_ = make(); + (*face_)->instance = loop_->instance; + (*face_)->matrix = loop_->matrix; + (*face_)->children = { clone(loop_) }; + } + return face_; +} + +boost::optional ifcopenshell::geometry::taxonomy::curve_to_edge_upgrade_impl(ptr item) { + boost::optional edge_; + auto circle_ = dcast(item); + auto ellipse_ = dcast(item); + auto line_ = dcast(item); + auto bspline_curve_ = dcast(item); + if (circle_ || ellipse_ || line_ || bspline_curve_) { + edge_ = make(); + if (circle_) { + (*edge_)->basis = circle_; + (*edge_)->instance = circle_->instance; + } else if (ellipse_) { + (*edge_)->basis = ellipse_; + (*edge_)->instance = ellipse_->instance; + } else if (line_) { + (*edge_)->basis = line_; + (*edge_)->instance = line_->instance; + } else if (bspline_curve_) { + (*edge_)->basis = bspline_curve_; + (*edge_)->instance = bspline_curve_->instance; + } + + if (circle_ || ellipse_) { + // @todo + (*edge_)->start = 0.; + (*edge_)->end = 2 * boost::math::constants::pi(); + } + } + return edge_; +} + +boost::optional ifcopenshell::geometry::taxonomy::curve_to_loop_upgrade_impl(ptr item) { + boost::optional loop_; + auto circle_ = dcast(item); + auto ellipse_ = dcast(item); + auto line_ = dcast(item); + auto bspline_curve_ = dcast(item); + if (circle_ || ellipse_ || line_ || bspline_curve_) { + auto edge_ = make(); + if (circle_) { + edge_->basis = circle_; + } else if (ellipse_) { + edge_->basis = ellipse_; + } else if (line_) { + edge_->basis = line_; + } else if (bspline_curve_) { + edge_->basis = bspline_curve_; + } + + if (circle_ || ellipse_) { + // @todo + edge_->start = 0.; + edge_->end = 2 * boost::math::constants::pi(); + } + + loop_ = make(); + (*loop_)->children.push_back(edge_); + } + return loop_; +} + +boost::optional ifcopenshell::geometry::taxonomy::edge_to_loop_upgrade_impl(ptr item) { + boost::optional loop_; + auto edge_ = dcast(item); + if (edge_) { + loop_ = make(); + (*loop_)->children.push_back(edge_); + } + return loop_; +} + +boost::optional ifcopenshell::geometry::taxonomy::curve_to_face_upgrade_impl(ptr item) { + boost::optional face_; + auto circle_ = dcast(item); + auto ellipse_ = dcast(item); + auto line_ = dcast(item); + auto bspline_curve_ = dcast(item); + + if (circle_ || ellipse_ || line_ || bspline_curve_) { + auto edge_ = make(); + if (circle_) { + edge_->basis = circle_; + } else if (ellipse_) { + edge_->basis = ellipse_; + } else if (line_) { + edge_->basis = line_; + } else if (bspline_curve_) { + edge_->basis = bspline_curve_; + } + + if (circle_ || ellipse_) { + // @todo + edge_->start = 0.; + edge_->end = 2 * boost::math::constants::pi(); + } + + auto loop_ = make(); + loop_->children.push_back(edge_); + + face_ = make(); + (*face_)->instance = loop_->instance; + (*face_)->matrix = loop_->matrix; + (*face_)->children = { clone(loop_) }; + } + return face_; +} + + +boost::optional ifcopenshell::geometry::taxonomy::loop_to_piecewise_function_upgrade_impl(ptr item) { + boost::optional pwf_; + auto loop_ = dcast(item); + if (loop_) { + if (loop_->pwf.is_initialized()) { + pwf_ = loop_->pwf; + } else { + 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"); + } + + 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(l, fn); + } + pwf_ = make(0.0,spans); + loop_->pwf = pwf_; + } + } + return pwf_; +} diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index 4dbb2dc6f8..2c119c9e4c 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -356,8 +356,6 @@ typedef item const* ptr; struct implicit_item : public geom_item { DECLARE_PTR(implicit_item) using geom_item::geom_item; - - virtual item::ptr evaluate() const = 0; }; struct piecewise_function_impl; // forward declaration @@ -366,14 +364,12 @@ typedef item const* ptr; using spans_t = std::vector>>; - piecewise_function(double start, const spans_t& s, ifcopenshell::geometry::Settings* settings = nullptr, const IfcUtil::IfcBaseInterface* instance = nullptr); - piecewise_function(double start, const std::vector& pwfs, ifcopenshell::geometry::Settings* settings = nullptr, const IfcUtil::IfcBaseInterface* instance = nullptr); + piecewise_function(double start, const spans_t& s, const IfcUtil::IfcBaseInterface* instance = nullptr); + piecewise_function(double start, const std::vector& pwfs, const IfcUtil::IfcBaseInterface* instance = nullptr); piecewise_function(piecewise_function&&) = default; piecewise_function(const piecewise_function&); virtual ~piecewise_function(); - const ifcopenshell::geometry::Settings* settings_ = nullptr; - const spans_t& spans() const; bool is_empty() const; double start() const; @@ -388,33 +384,6 @@ typedef item const* ptr; return boost::hash{}(v); } - /// @brief returns a vector of "distance along" points where the evaluate function computes loop points - std::vector evaluation_points() const; - - /// @brief returns a vector of "distance along" points between ustart and uend - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - std::vector evaluation_points(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function between start and end - /// evaluation point step size is taken from the settings object - item::ptr evaluate() const override; - - /// @brief evaluates the piecewise function between ustart and uend - /// if ustart and uend are out of range, the range of values evaluated - /// are constrained to start_ and start_+length_ - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - /// @return taxonomy::loop::ptr - item::ptr evaluate(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function at u - /// @param u u is constrained to be between start_ and start_+length - /// @return 4x4 placement matrix - Eigen::Matrix4d evaluate(double u) const; - private: // note: it would be better if this were a std::unique_ptr, but that requires having the full definition // of piecewise_function_impl in this header file, which defeats the purpose of the PIMPL idiom. @@ -850,7 +819,7 @@ typedef item const* ptr; } }; - struct loft : public collection_base { + struct loft : public collection_base { DECLARE_PTR(loft) item::ptr axis; @@ -1075,6 +1044,7 @@ typedef item const* ptr; typedef std::tuple KindsTuple; typedef std::tuple CurvesTuple; typedef std::tuple SurfacesTuple; + typedef std::tuple UpgradesTuple; } struct type_by_kind { @@ -1098,6 +1068,14 @@ typedef item const* ptr; static const size_t max = std::tuple_size::value; }; + struct upgrades { + template + using type = typename std::tuple_element::type; + + static const size_t max = std::tuple_size::value; + }; + + boost::optional loop_to_face_upgrade_impl(ptr item); template class loop_to_face_upgrade { private: @@ -1105,15 +1083,7 @@ typedef item const* ptr; public: loop_to_face_upgrade(taxonomy::ptr item) { if constexpr (std::is_same_v) { - auto loop = taxonomy::dcast(item); - if (loop) { - loop->external = true; - - face_ = taxonomy::make(); - (*face_)->instance = loop->instance; - (*face_)->matrix = loop->matrix; - (*face_)->children = { taxonomy::clone(loop) }; - } + face_ = loop_to_face_upgrade_impl(item); } } @@ -1131,6 +1101,7 @@ typedef item const* ptr; } }; + boost::optional curve_to_edge_upgrade_impl(ptr item); template class curve_to_edge_upgrade { private: @@ -1138,28 +1109,7 @@ typedef item const* ptr; public: curve_to_edge_upgrade(taxonomy::ptr item) { if constexpr (std::is_same_v) { - auto circle = taxonomy::dcast(item); - auto ellipse = taxonomy::dcast(item); - auto line = taxonomy::dcast(item); - auto bspline_curve = taxonomy::dcast(item); - if (circle || ellipse || line || bspline_curve) { - edge_ = taxonomy::make(); - if (circle) { - (*edge_)->basis = circle; - } else if (ellipse) { - (*edge_)->basis = ellipse; - } else if (line) { - (*edge_)->basis = line; - } else if (bspline_curve) { - (*edge_)->basis = bspline_curve; - } - - if (circle || ellipse) { - // @todo - (*edge_)->start = 0.; - (*edge_)->end = 2 * boost::math::constants::pi(); - } - } + edge_ = taxonomy::curve_to_edge_upgrade_impl(item); } } @@ -1177,7 +1127,7 @@ typedef item const* ptr; } }; - + boost::optional curve_to_loop_upgrade_impl(ptr item); template class curve_to_loop_upgrade { private: @@ -1185,31 +1135,7 @@ typedef item const* ptr; public: curve_to_loop_upgrade(taxonomy::ptr item) { if constexpr (std::is_same_v) { - auto circle = taxonomy::dcast(item); - auto ellipse = taxonomy::dcast(item); - auto line = taxonomy::dcast(item); - auto bspline_curve = taxonomy::dcast(item); - if (circle || ellipse || line || bspline_curve) { - auto edge = taxonomy::make(); - if (circle) { - edge->basis = circle; - } else if (ellipse) { - edge->basis = ellipse; - } else if (line) { - edge->basis = line; - } else if (bspline_curve) { - edge->basis = bspline_curve; - } - - if (circle || ellipse) { - // @todo - edge->start = 0.; - edge->end = 2 * boost::math::constants::pi(); - } - - loop_ = taxonomy::make(); - (*loop_)->children.push_back(edge); - } + loop_ = curve_to_loop_upgrade_impl(item); } } @@ -1227,6 +1153,7 @@ typedef item const* ptr; } }; + boost::optional edge_to_loop_upgrade_impl(ptr item); template class edge_to_loop_upgrade { private: @@ -1234,11 +1161,7 @@ typedef item const* ptr; public: edge_to_loop_upgrade(taxonomy::ptr item) { if constexpr (std::is_same_v) { - auto edge = taxonomy::dcast(item); - if (edge) { - loop_ = taxonomy::make(); - (*loop_)->children.push_back(edge); - } + loop_ = edge_to_loop_upgrade_impl(item); } } @@ -1256,7 +1179,7 @@ typedef item const* ptr; } }; - + boost::optional curve_to_face_upgrade_impl(ptr item); template class curve_to_face_upgrade { private: @@ -1264,36 +1187,7 @@ typedef item const* ptr; public: curve_to_face_upgrade(taxonomy::ptr item) { if constexpr (std::is_same_v) { - auto circle = taxonomy::dcast(item); - auto ellipse = taxonomy::dcast(item); - auto line = taxonomy::dcast(item); - auto bspline_curve = taxonomy::dcast(item); - if (circle || ellipse || line || bspline_curve) { - auto edge = taxonomy::make(); - if (circle) { - edge->basis = circle; - } else if (ellipse) { - edge->basis = ellipse; - } else if (line) { - edge->basis = line; - } else if (bspline_curve) { - edge->basis = bspline_curve; - } - - if (circle || ellipse) { - // @todo - edge->start = 0.; - edge->end = 2 * boost::math::constants::pi(); - } - - auto loop = taxonomy::make(); - loop->children.push_back(edge); - - face_ = taxonomy::make(); - (*face_)->instance = loop->instance; - (*face_)->matrix = loop->matrix; - (*face_)->children = { taxonomy::clone(loop) }; - } + face_ = curve_to_face_upgrade_impl(item); } } @@ -1311,6 +1205,7 @@ typedef item const* ptr; } }; + boost::optional loop_to_piecewise_function_upgrade_impl(ptr item); template class loop_to_piecewise_function_upgrade { private: @@ -1319,36 +1214,7 @@ typedef item const* ptr; public: loop_to_piecewise_function_upgrade(taxonomy::ptr item) { if constexpr (std::is_same_v) { - auto loop = taxonomy::dcast(item); - if (loop) { - if (loop->pwf.is_initialized()) { - pwf_ = loop->pwf; - } else { - taxonomy::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"); - } - - 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 taxonomy::make(o, axis, refDirection)->components(); - }; - spans.emplace_back(l, fn); - } - pwf_ = taxonomy::make(0.0,spans); - loop->pwf = pwf_; - } - } + pwf_ = loop_to_piecewise_function_upgrade_impl(item); } } @@ -1382,13 +1248,13 @@ typedef item const* ptr; } } { - edge_to_loop_upgrade upg(u); + curve_to_face_upgrade upg(u); if (upg) { return upg; } } { - curve_to_face_upgrade upg(u); + edge_to_loop_upgrade upg(u); if (upg) { return upg; } @@ -1419,6 +1285,12 @@ typedef item const* ptr; return upg; } } + { + curve_to_loop_upgrade upg(u); + if (upg) { + return upg; + } + } { curve_to_face_upgrade upg(u); if (upg) { diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index c76a6fddb9..7c762c8113 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -52,8 +52,8 @@ ifeq ($(PLATFORM), win64) PLATFORMTAG:=win_amd64 endif -IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v0.7.11-d51fa2c-$(PLATFORM).zip -IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-d51fa2c-$(PLATFORM).zip +IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v0.8.1-a51ed6e-$(PLATFORM).zip +IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.8.1-a51ed6e-$(PLATFORM).zip .PHONY: test test: diff --git a/src/ifcopenshell-python/docs/ifcconvert.rst b/src/ifcopenshell-python/docs/ifcconvert.rst index 29647aa7c1..bc17accf52 100644 --- a/src/ifcopenshell-python/docs/ifcconvert.rst +++ b/src/ifcopenshell-python/docs/ifcconvert.rst @@ -14,7 +14,7 @@ table below. | | .igs, .xml, .svg, .h5, | | | | .ifc | | +-------------------------+-------------------------+----------------------+ -| .ifc | .dae, .abc, .usd, .obj, | `Bonsai`_ | +| .ifc | .dae, .abc, .usd, .obj, | Bonsai_ | | | .ply, .stl, .fbx, .glb, | | | | .gltf, .x3d, .dxf | | +-------------------------+-------------------------+----------------------+ @@ -41,9 +41,9 @@ table below. +-------------------------+-------------------------+----------------------+ | .csv | .ifc | IfcCSV_ | +-------------------------+-------------------------+----------------------+ -| .dxf | .ifc | `Bonsai`_ | +| .dxf | .ifc | Bonsai_ | +-------------------------+-------------------------+----------------------+ -| .obj | .ifc | `Bonsai`_ | +| .obj | .ifc | Bonsai_ | +-------------------------+-------------------------+----------------------+ | .json (CityJSON) | .ifc | IfcCityJSON_ | +-------------------------+-------------------------+----------------------+ @@ -64,7 +64,7 @@ table below. .. _Ifc5D: ifc5d.html .. _IfcCityJSON: ifccityjson.html .. _Ifc2JSON: other.html -.. _Bonsai: https://bonsaibim.org +.. _Bonsai: bonsai.html .. toctree:: :hidden: diff --git a/src/ifcopenshell-python/docs/ifccsv.rst b/src/ifcopenshell-python/docs/ifccsv.rst index c3f3666c19..536c564469 100644 --- a/src/ifcopenshell-python/docs/ifccsv.rst +++ b/src/ifcopenshell-python/docs/ifccsv.rst @@ -68,7 +68,7 @@ interface to access the IfcOpenShell utilities. 1. Install Bonsai by following the `Bonsai installation documentation - `_. + `_. 2. Launch Blender. Change to the **Scene Properties** tab in the **Properties Panel**. Scroll down to the **IFC Collaboration > IFC CSV Import / Export** diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/code_examples.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/code_examples.rst index 90b8806057..94e45ba295 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/code_examples.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/code_examples.rst @@ -263,7 +263,7 @@ Create a simple model from scratch ifcopenshell.api.geometry.assign_representation(model, product=wall, representation=representation) # Place our wall in the ground floor - ifcopenshell.api.spatial.assign_container(model, relating_structure=storey, product=wall) + ifcopenshell.api.spatial.assign_container(model, relating_structure=storey, products=[wall]) # Write out to a file model.write("/home/dion/model.ifc") diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst index 0a7565d258..80901ddb20 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst @@ -195,7 +195,7 @@ The BlenderBIM Add-on is available either as a stable build or a daily build. 1. Install the BlenderBIM Add-on by following the `BlenderBIM Add-on installation documentation - `_. + `_. 2. Launch Blender. On the top left of the Viewport panel, click the **Editor Type** icon to change the viewport into a **Python Console**. @@ -229,7 +229,7 @@ and run your script using the **Text > Run Script** menu or by clicking on the Blender. This can help when learning how to write scripts as you can double check the results of your scripts with what you see in the graphical interface. `Read more - `_. + `_. From source with precompiled binaries ------------------------------------- diff --git a/src/ifcopenshell-python/docs/ifcopenshell/geometry_settings.rst b/src/ifcopenshell-python/docs/ifcopenshell/geometry_settings.rst index b525c80fcc..1299fe2e27 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell/geometry_settings.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell/geometry_settings.rst @@ -25,7 +25,7 @@ Here's an example of changing settings in Python: .. code-block:: python settings = ifcopenshell.geom.settings() - settings.set(settings.APPLY_DEFAULT_MATERIALS, True) + settings.set("apply-default-materials", True) settings.set_deflection_tolerance(1e-3) angular_tolerance @@ -53,7 +53,7 @@ Here is an example in Python: .. code-block:: python settings = ifcopenshell.geom.settings() - settings.set_angular_tolerance(0.5) + settings.set("mesher-angular-deflection", 0.5) APPLY_DEFAULT_MATERIALS ----------------------- @@ -61,7 +61,7 @@ APPLY_DEFAULT_MATERIALS +------+-------------------+---------+ | Type | IfcConvert Option | Default | +======+===================+=========+ -| BOOL | Always enabled | False | +| BOOL | Always enabled | True | +------+-------------------+---------+ Given the command invocation: @@ -82,6 +82,8 @@ The interactive session below shows how with this setting enabled you will get a >>> import ifcopenshell, ifcopenshell.geom >>> f = ifcopenshell.open("model.ifc") >>> s = ifcopenshell.geom.settings() + >>> # By default setting is enabled, we disable it for demonstration. + >>> s.set("apply-default-materials", False) >>> c = f["3bXiCStxP6Fgxdej$yc50U"] >>> >>> shp = ifcopenshell.geom.create_shape(s, c) @@ -90,12 +92,12 @@ The interactive session below shows how with this setting enabled you will get a >>> [(m.name, m.diffuse) for m in shp.geometry.materials] [] >>> - >>> s.set(s.APPLY_DEFAULT_MATERIALS, True) + >>> s.set("apply-default-materials", True) >>> shp = ifcopenshell.geom.create_shape(s, c) >>> shp.geometry.material_ids (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) >>> [(m.name, m.diffuse) for m in shp.geometry.materials] - [('IfcCovering', (0.7, 0.7, 0.7))] + [('IfcCovering', colour 0.7 0.7 0.7)] This is enabled by default for the IfcConvert serializers as they will not gracefully handle -1 material indices and allows users to quickly assign colours based on entity types in their modelling applications. @@ -178,7 +180,7 @@ Here is an example in Python: settings = ifcopenshell.geom.settings() contexts = [c.id() for c in ifc_file.by_type("IfcGeometricRepresentationContext") if c.ContextIdentifier == "Body"] - settings.set_context_ids(contexts) + settings.set("context-ids", contexts) CONVERT_BACK_UNITS @@ -228,7 +230,7 @@ Here is an example in Python: .. code-block:: python settings = ifcopenshell.geom.settings() - settings.set_deflection_tolerance(1e-3) + settings.set("mesher-linear-deflection", 1e-3) DISABLE_BOOLEAN_RESULT ---------------------- @@ -273,7 +275,7 @@ For example, if you want to set this setting in a python script you can use the .. code-block:: python settings = ifcopenshell.geom.settings() - settings.set(settings.DISABLE_OPENING_SUBTRACTIONS, True) + settings.set("disable-opening-subtractions", True) DISABLE_TRIANGULATION @@ -611,7 +613,8 @@ USE_PYTHON_OPENCASCADE Only available in Python when an import of ``OCC.Core.BRepTools`` or ``OCC.BRepTools`` succeeds. -This implies ``USE_WORLD_COORDS`` ``USE_BREP_DATA`` and ``DISABLE_TRIANGULATION``. The serialized TopoDS_Shape of ``USE_BREP_DATA`` is deserialized by Python OpenCASCADE. +This implies ``USE_WORLD_COORDS`` and ``ITERATOR_OUTPUT`` set to ``SERIALIZED``. +The serialized TopoDS_Shape from iterator output is deserialized by Python OpenCASCADE. USE_WORLD_COORDS ---------------- diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 1b4698f2eb..99b5b737cd 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -53,13 +53,16 @@ Example: for wall in walls: print(wall.Name) """ - +from __future__ import annotations import os import sys import zipfile import tempfile from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, TYPE_CHECKING, Any, overload, Literal + +if TYPE_CHECKING: + import ifcopenshell.express.schema_class if hasattr(os, "uname"): @@ -116,9 +119,24 @@ class SchemaError(Error): pass -def open(path: Union[os.PathLike, str], format: Optional[str] = None, should_stream: bool = False) -> file: +@overload +def open( + path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: Literal[False] = False +) -> Union[file, sqlite]: ... +@overload +def open(path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: Literal[True]) -> stream: ... +@overload +def open( + path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: bool +) -> Union[file, sqlite, stream]: ... +def open( + path: Union[os.PathLike, str], format: Optional[str] = None, should_stream: bool = False +) -> Union[file, sqlite, stream]: """Loads an IFC dataset from a filepath + :param should_stream: Whether to open the file in streaming mode. Could be useful + for reading large files. + You can specify a file format. If no format is given, it is guessed from its extension. Currently supported specified format: .ifc | .ifcZIP | .ifcXML. @@ -161,20 +179,17 @@ def open(path: Union[os.PathLike, str], format: Optional[str] = None, should_str return file(f) -def create_entity(type, schema="IFC4", *args, **kwargs): +def create_entity(type: str, schema: str = "IFC4", *args: Any, **kwargs: Any) -> entity_instance: """Creates a new IFC entity that does not belong to an IFC file object Note that it is more common to create entities within a existing file object. See :meth:`ifcopenshell.file.create_entity`. :param type: Case insensitive name of the IFC class - :type type: string :param schema: The IFC schema identifier - :type schema: string :param args: The positional arguments of the IFC class :param kwargs: The keyword arguments of the IFC class :returns: An entity instance - :rtype: ifcopenshell.entity_instance Example: @@ -191,11 +206,10 @@ def create_entity(type, schema="IFC4", *args, **kwargs): return e -def register_schema(schema): +def register_schema(schema: ifcopenshell.express.schema_class.SchemaClass) -> None: """Registers a custom IFC schema :param schema: A schema object - :type schema: ifcopenshell.express.schema_class.SchemaClass Example: @@ -212,13 +226,13 @@ def register_schema(schema): def schema_by_name( - schema: Optional[str] = None, schema_version: Optional[tuple[int, ...]] = None + schema: Optional[str] = None, + schema_version: Optional[tuple[int, ...]] = None, ) -> ifcopenshell_wrapper.schema_definition: """Returns an object allowing you to query the IFC schema itself :param schema: Which IFC schema to use, chosen from "IFC2X3", "IFC4", or "IFC4X3". These refer to the ISO approved versions of IFC. - :type schema: string, optional :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 @@ -227,10 +241,9 @@ def schema_by_name( ADD2 TC1, which is the official version approved by ISO when people refer to "IFC4". Generally you should not use this argument unless you are testing non-ISO IFC releases. - :type schema_version: tuple[int, ...], optional :return: Schema definition object. - :rtype: ifocpenshell_wrapper.schema_definition """ + assert schema_version or schema, "Either schema or schema_version must be specified." if schema_version: prefixes = ("IFC", "X", "_ADD", "_TC") schema = "".join("".join(map(str, t)) if t[1] else "" for t in zip(prefixes, schema_version)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py b/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py index 3babb19013..3cbab7f38d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py +++ b/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py @@ -18,7 +18,8 @@ import ifcopenshell.api.owner import ifcopenshell.util.element -from typing import Any +from typing import Any, Union +from types import EllipsisType def edit_attributes(file: ifcopenshell.file, product: ifcopenshell.entity_instance, attributes: dict[str, Any]) -> None: @@ -29,6 +30,13 @@ def edit_attributes(file: ifcopenshell.file, product: ifcopenshell.entity_instan to also ensure that ownership history is updated. This function provides that convenience. + The method will maintain consistency for PredefinedType attribute + based on whether ElementType/ObjectType and whether occurrence is typed: + + - PredefinedType and ObjectType to be `None` if occurrence is typed + - PredefinedType to be "NOTDEFINED" if ElementType/ObjectType is None + - PredefinedType to be "USERDEFINED" if ElementType/ObjectType is not None + :param product: The product you want to edit. This may be any rooted IFC entity. :type product: ifcopenshell.entity_instance @@ -45,25 +53,35 @@ def edit_attributes(file: ifcopenshell.file, product: ifcopenshell.entity_instan ifcopenshell.api.attribute.edit_attributes(model, product=element, attributes={"Name": "Waldo"}) """ - settings = {"product": product, "attributes": attributes or {}} - for name, value in settings["attributes"].items(): - setattr(settings["product"], name, value) - if hasattr(settings["product"], "PredefinedType"): - if hasattr(settings["product"], "ElementType"): - if settings["product"].ElementType is None and settings["product"].PredefinedType == "USERDEFINED": - settings["product"].PredefinedType = "NOTDEFINED" - elif settings["product"].ElementType and settings["product"].PredefinedType != "USERDEFINED": - settings["product"].PredefinedType = "USERDEFINED" - elif hasattr(settings["product"], "ObjectType"): - relating_type = ifcopenshell.util.element.get_type(settings["product"]) + def getattr_safe(element: ifcopenshell.entity_instance, attr: str) -> Union[Any, EllipsisType]: + """Return attribute value or Ellipsis if attribute doesn't exist. + + Useful as an alternative to hasattr - hasattr under the hood just does + getattr but doesn't return a value. That helps reduce times IFC is accessed. + """ + return getattr(element, attr, ...) + + for name, value in attributes.items(): + setattr(product, name, value) + + if (predefined_type := getattr_safe(product, "PredefinedType")) is not ...: + if (element_type := getattr_safe(product, "ElementType")) is not ...: + if element_type is None and predefined_type == "USERDEFINED": + product.PredefinedType = "NOTDEFINED" + elif element_type and predefined_type != "USERDEFINED": + product.PredefinedType = "USERDEFINED" + + elif (object_type := getattr_safe(product, "ObjectType")) is not ...: + relating_type = ifcopenshell.util.element.get_type(product) # Allow for None due to https://github.com/buildingSMART/IFC4.3.x-development/issues/818 if relating_type and relating_type.PredefinedType not in ("NOTDEFINED", None): - settings["product"].ObjectType = None - settings["product"].PredefinedType = None - elif settings["product"].ObjectType is None and settings["product"].PredefinedType == "USERDEFINED": - settings["product"].PredefinedType = "NOTDEFINED" - elif settings["product"].ObjectType and settings["product"].PredefinedType != "USERDEFINED": - settings["product"].PredefinedType = "USERDEFINED" - if hasattr(settings["product"], "OwnerHistory"): - ifcopenshell.api.owner.update_owner_history(file, **{"element": settings["product"]}) + product.ObjectType = None + product.PredefinedType = None + elif object_type is None and predefined_type == "USERDEFINED": + product.PredefinedType = "NOTDEFINED" + elif object_type and predefined_type != "USERDEFINED": + product.PredefinedType = "USERDEFINED" + + if hasattr(product, "OwnerHistory"): + ifcopenshell.api.owner.update_owner_history(file, **{"element": product}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py index a1c0b9a706..542347fa11 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py @@ -104,12 +104,14 @@ class Usecase: if self.settings["prop_name"]: self.quantities = set(self.settings["cost_item"].CostQuantities or []) for product in self.settings["products"]: + if not product.is_a("IfcElement"): + continue self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"]) if self.settings["prop_name"]: if ( self.settings["cost_item"].CostQuantities and self.settings["cost_item"].CostQuantities[0].Name.lower() != self.settings["prop_name"].lower() - ) or not product.is_a("IfcObject"): + ): continue self.add_quantity_from_related_object(product) if self.settings["prop_name"]: 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 c58bd7e657..b160803d0a 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 @@ -102,9 +102,10 @@ def calculate_cost_item_resource_value(file: ifcopenshell.file, cost_item: ifcop for resource in resources: cost, unit = ifcopenshell.util.resource.get_cost(resource) if not cost: - cost, unit = ifcopenshell.util.resource.get_parent_cost( - resource - ) # Concept to standardise - Not defined in schema, but this makes manual scheduling of resources 10x faster and less duplicate data. + # Concept to standardise - Not defined in schema, but this makes manual scheduling of resources 10x faster and less duplicate data. + parent_cost = ifcopenshell.util.resource.get_parent_cost(resource) + assert parent_cost + cost, unit = parent_cost quantity = ifcopenshell.util.resource.get_quantity(resource) if not cost or not quantity: continue diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/assign_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/assign_representation.py index 33c012fd3f..d2dee0a86e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/assign_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/assign_representation.py @@ -19,6 +19,7 @@ import ifcopenshell.api.owner import ifcopenshell.api.geometry import ifcopenshell.util.element +from typing import Any def assign_representation( @@ -31,7 +32,10 @@ def assign_representation( class Usecase: - def execute(self): + file: ifcopenshell.file + settings: dict[str, Any] + + def execute(self) -> None: if self.settings["product"].is_a("IfcProduct"): product_type = ifcopenshell.util.element.get_type(self.settings["product"]) if ( @@ -73,7 +77,9 @@ class Usecase: self.assign_product_representation(element, mapped_representation) ifcopenshell.api.owner.update_owner_history(self.file, **{"element": self.settings["product"]}) - def assign_product_representation(self, product, representation): + def assign_product_representation( + self, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance + ) -> None: definition = product.Representation if not definition: definition = self.file.createIfcProductDefinitionShape() diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/unassign_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/unassign_representation.py index 6396914fb4..bc9f4d5453 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/unassign_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/unassign_representation.py @@ -18,6 +18,7 @@ import ifcopenshell.api.geometry import ifcopenshell.util.element +from typing import Any def unassign_representation( @@ -30,26 +31,35 @@ def unassign_representation( class Usecase: - def execute(self): - if self.settings["product"].is_a("IfcProduct"): - self.unassign_product_representation(self.settings["product"], self.settings["representation"]) - elif self.settings["product"].is_a("IfcTypeProduct"): + file: ifcopenshell.file + settings: dict[str, Any] + + def execute(self) -> None: + product: ifcopenshell.entity_instance = self.settings["product"] + representation: ifcopenshell.entity_instance = self.settings["representation"] + if product.is_a("IfcProduct"): + self.unassign_product_representation(product, representation) + elif product.is_a("IfcTypeProduct"): self.unassign_type_representation() - def unassign_product_representation(self, product, representation): + def unassign_product_representation( + self, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance + ) -> None: representations = list(product.Representation.Representations or []) if representation not in representations: return representations.remove(representation) if not representations: - self.file.remove(product.Representation) + product_def = product.Representation + # TODO: should somehow find matching shape aspect and remove it + # even before the last representation is removed. + self.process_shape_aspects(product_def) + self.file.remove(product_def) else: product.Representation.Representations = representations - def unassign_type_representation(self): - + def unassign_type_representation(self) -> None: matching_representation_map = None - representation_maps = self.settings["product"].RepresentationMaps or [] for representation_map in self.settings["product"].RepresentationMaps or []: if representation_map.MappedRepresentation == self.settings["representation"]: @@ -61,13 +71,31 @@ class Usecase: self.settings["product"].RepresentationMaps = [ rm for rm in self.settings["product"].RepresentationMaps if rm != matching_representation_map ] or None + self.process_shape_aspects(matching_representation_map) self.remove_representation_map_only(matching_representation_map) - def remove_representation_map_only(self, representation_map): + def process_shape_aspects(self, product_representation: ifcopenshell.entity_instance) -> None: + # Technically IfcShapeAspect doesn't become invalid when product representation is removed, + # but shape aspect makes sense only in context of some other representation. + if self.file.schema == "IFC2X3" and product_representation.is_a("IfcRepresentationMap"): + shape_aspects = [ + a + for a in self.file.by_type("IfcShapeAspect") + if a.PartOfProductDefinitionShape == product_representation + ] + else: + shape_aspects = product_representation.HasShapeAspects + for shape_aspect in shape_aspects: + representations = shape_aspect.ShapeRepresentations + self.file.remove(shape_aspect) + for rep in representations: + ifcopenshell.api.geometry.remove_representation(self.file, rep) + + def remove_representation_map_only(self, representation_map: ifcopenshell.entity_instance) -> None: representation_map.MappedRepresentation = self.file.createIfcShapeRepresentation() ifcopenshell.util.element.remove_deep2(self.file, representation_map) - def unassign_products_using_mapped_representation(self, representation_map): + def unassign_products_using_mapped_representation(self, representation_map: ifcopenshell.entity_instance) -> None: mapped_representations = [] just_representations = [] for map_usage in representation_map.MapUsage or []: diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py index 3fbb4dd7f0..bce415d55b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py @@ -33,7 +33,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/users/advanced/georeferencing.html + https://docs.bonsaibim.org/guides/advanced/georeferencing.html For more information about the attributes and data types of an IfcCoordinateOperation, consult the IFC documentation. 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 a1c5d6f942..7e3840753b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/grid/create_axis_curve.py +++ b/src/ifcopenshell-python/ifcopenshell/api/grid/create_axis_curve.py @@ -72,15 +72,14 @@ def create_axis_curve( class Usecase: def execute(self): existing_curve = self.settings["grid_axis"].AxisCurve - if existing_curve and len(self.file.get_inverse(existing_curve)) == 1: - ifcopenshell.util.element.remove_deep(self.file, existing_curve) - self.file.remove(existing_curve) 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 = [ - Matrix(ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)).inverted() - @ (self.settings["axis_curve"].matrix_world @ v.co) + 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( @@ -90,13 +89,13 @@ class Usecase: ] ) - def create_cartesian_point(self, x, y, z=None): + 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) - if z is None: - return self.file.createIfcCartesianPoint((x, y)) - z = self.convert_si_to_unit(z) - return self.file.createIfcCartesianPoint((x, y, z)) + return self.file.createIfcCartesianPoint((x, y)) def convert_si_to_unit(self, co): return co / self.settings["unit_scale"] diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py b/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py index 0add4abf47..efc345ef37 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py @@ -37,6 +37,9 @@ def add_profile( profile set, whereas the "profile curve" represents a 2D curve used by a "profile item". + Profile is not optional for IfcMaterialProfile but it is optional for this API + call and can be assigned later with material.assign_profile. + In some cases, a profiled element (i.e. beam, column) may be a composite beam or column and include multiple extrusions. This is rare. The order of the profiles does not matter. diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/copy_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/copy_material.py index b98490ef07..d250ac742b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/copy_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/copy_material.py @@ -33,9 +33,7 @@ def copy_material(file: ifcopenshell.file, material: ifcopenshell.entity_instanc style is reused. :param material: The IfcMaterialDefinition to copy - :type material: ifcopenshell.entity_instance :return: The new copy of the material - :rtype: ifcopenshell.entity_instance Example: @@ -68,9 +66,13 @@ def copy_material(file: ifcopenshell.file, material: ifcopenshell.entity_instanc return _copy_material_with_inverses(file, material) elif material.is_a("IfcMaterialList"): return _copy_material_with_inverses(file, material) + else: + raise Exception(f"Unexpected material type: '{material.is_a()}' ({material}).") -def _copy_material_with_inverses(file, material): +def _copy_material_with_inverses( + file: ifcopenshell.file, material: ifcopenshell.entity_instance +) -> ifcopenshell.entity_instance: new = ifcopenshell.util.element.copy(file, material) for inverse in file.get_inverse(material): if inverse.is_a("IfcMaterialProperties"): diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/profile/__init__.py index e26d7725b1..bb882c999a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/__init__.py @@ -26,6 +26,7 @@ from .. import wrap_usecases from .add_arbitrary_profile import add_arbitrary_profile from .add_arbitrary_profile_with_voids import add_arbitrary_profile_with_voids from .add_parameterized_profile import add_parameterized_profile +from .copy_profile import copy_profile from .edit_profile import edit_profile from .remove_profile import remove_profile @@ -35,6 +36,7 @@ __all__ = [ "add_arbitrary_profile", "add_arbitrary_profile_with_voids", "add_parameterized_profile", + "copy_profile", "edit_profile", "remove_profile", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py new file mode 100644 index 0000000000..0da06f2beb --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py @@ -0,0 +1,47 @@ +# 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 +import ifcopenshell.util.element + + +def copy_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Copies a profile + + All profile's psets are copied. The copied profile is not + associated to any elements. + + :param profile: The IfcProfileDef to copy + :return: The new copy of the profile + + Example: + + .. code:: python + + profile = ifcopenshell.api.profile.add_profile(model, ifc_class="IfcRectangleProfileDef") + + # Let's duplicate the rectangle profile + profile_copy = ifcopenshell.api.profile.copy_profile(model, profile=profile) + """ + new_profile = ifcopenshell.util.element.copy_deep(file, profile) + inverses = file.get_inverse(profile) + psets = [i for i in inverses if i.is_a("IfcProfileProperties")] + for pset in psets: + new_pset = ifcopenshell.util.element.copy(file, pset) + new_pset.ProfileDefinition = new_profile + return new_profile diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py index aeadafdb5a..5e3816fb93 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py @@ -17,6 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.api.pset import ifcopenshell.util.element @@ -24,9 +25,6 @@ def remove_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instanc """Removes a profile :param profile: The IfcProfileDef to remove. - :type profile: ifcopenshell.entity_instance - :return: None - :rtype: None Example: @@ -38,11 +36,26 @@ def remove_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instanc ifcopenshell.api.profile.remove_profile(model, profile=circle) """ settings = {"profile": profile} + is_ifc2x3 = file.schema == "IFC2X3" subelements = set() for attribute in settings["profile"]: if isinstance(attribute, ifcopenshell.entity_instance): subelements.add(attribute) + + # Clean up profile proprty sets. + profile_psets = [] + if is_ifc2x3: + for pset in file.by_type("IfcProfileProperties"): + if pset.ProfileDefinition != profile: + continue + profile_psets.append(pset) + else: + profile_psets = profile.HasProperties + + for pset in profile_psets: + ifcopenshell.api.pset.remove_pset(file, product=profile, pset=pset) + file.remove(settings["profile"]) for subelement in subelements: ifcopenshell.util.element.remove_deep2(file, subelement) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py index dd900a473a..90333d164a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py @@ -26,16 +26,22 @@ For example, if a door has a fire rating, it is stored as a property. from .. import wrap_usecases from .add_pset import add_pset from .add_qto import add_qto +from .assign_pset import assign_pset from .edit_pset import edit_pset from .edit_qto import edit_qto from .remove_pset import remove_pset +from .unassign_pset import unassign_pset +from .unshare_pset import unshare_pset wrap_usecases(__path__, __name__) __all__ = [ "add_pset", "add_qto", + "assign_pset", "edit_pset", "edit_qto", "remove_pset", + "unassign_pset", + "unshare_pset", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py index 776c611e78..ffa31faa8e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py @@ -18,10 +18,17 @@ import ifcopenshell import ifcopenshell.api.owner +import ifcopenshell.api.pset import ifcopenshell.guid +from typing import Optional -def add_pset(file: ifcopenshell.file, product: ifcopenshell.entity_instance, name: str) -> ifcopenshell.entity_instance: +def add_pset( + file: ifcopenshell.file, + product: ifcopenshell.entity_instance, + name: str, + ifc2x3_subclass: Optional[str] = None, +) -> ifcopenshell.entity_instance: """Adds a new property set to a product Products, such as physical objects or types in IFC may have properties @@ -54,18 +61,21 @@ def add_pset(file: ifcopenshell.file, product: ifcopenshell.entity_instance, nam data, rather than arbitrary metadata. :param product: The IfcObject that you want to assign a property set to. - :type product: ifcopenshell.entity_instance :param name: The name of the property set. Property sets that are standardised by buildingSMART typically have a prefix of "Pset_", like "Pset_WallCommon". If you create your own, you must not use that prefix. It is recommended to use your own prefix tailored to your project, company, or local government requirement. - :type name: str + :param ifc2x3_subclass: IFC2X3 subclass for material or profile properties. + In IFC2X3 IfcProfileProperties and IfcMaterialProperties are abstract + so you need one of their subclasses to instantiate them. + By default, for profile will be created IfcGeneralProfileProperties + and for material - IfcExtendedMaterialProperties. + Will have no effect in >=IFC4. :raises TypeError: If `product` class doesn't support adding a pset. :return: The newly created IfcPropertySet - :rtype: ifcopenshell.entity_instance Example: @@ -83,6 +93,7 @@ def add_pset(file: ifcopenshell.file, product: ifcopenshell.entity_instance, nam 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 []: @@ -97,16 +108,9 @@ def add_pset(file: ifcopenshell.file, product: ifcopenshell.entity_instance, nam "Name": settings["name"], }, ) - file.create_entity( - "IfcRelDefinesByProperties", - **{ - "GlobalId": ifcopenshell.guid.new(), - "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["product"]], - "RelatingPropertyDefinition": pset, - }, - ) + ifcopenshell.api.pset.assign_pset(file, [settings["product"]], pset) return pset + elif settings["product"].is_a("IfcTypeObject"): for definition in settings["product"].HasPropertySets or []: if definition.Name == settings["name"]: @@ -120,30 +124,28 @@ def add_pset(file: ifcopenshell.file, product: ifcopenshell.entity_instance, nam "Name": settings["name"], }, ) - has_property_sets = list(settings["product"].HasPropertySets or []) - has_property_sets.append(pset) - settings["product"].HasPropertySets = has_property_sets + ifcopenshell.api.pset.assign_pset(file, [settings["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"]} if file.schema == "IFC2X3": - ifc_class = "IfcExtendedMaterialProperties" + ifc_class = ifc2x3_subclass or "IfcExtendedMaterialProperties" definitions = (d for d in file.by_type("IfcMaterialProperties") if d.Material == settings["product"]) + if ifc_class == "IfcExtendedMaterialProperties": + kwargs["Name"] = settings["name"] else: ifc_class = "IfcMaterialProperties" definitions = settings["product"].HasProperties + kwargs["Name"] = settings["name"] for definition in definitions: # In IFC2X3 not all IfcMaterialProperties has Name - if getattr(definition, "Name") == settings["name"]: + if getattr(definition, "Name", None) == settings["name"]: return definition - return file.create_entity( - ifc_class, - **{ - "Name": settings["name"], - "Material": settings["product"], - }, - ) + return file.create_entity(ifc_class, **kwargs) + elif settings["product"].is_a("IfcProfileDef"): # in IFC2X3 IfcProfileProperties doesn't have Name and we cannot identify them if file.schema != "IFC2X3": @@ -156,6 +158,10 @@ def add_pset(file: ifcopenshell.file, product: ifcopenshell.entity_instance, nam if file.schema != "IFC2X3": kwargs["Name"] = settings["name"] - return file.create_entity("IfcProfileProperties", **kwargs) + if is_ifc2x3: + ifc_class = ifc2x3_subclass or "IfcGeneralProfileProperties" + else: + 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.") diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/assign_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/assign_pset.py new file mode 100644 index 0000000000..ed309944a1 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/assign_pset.py @@ -0,0 +1,94 @@ +# 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 +import ifcopenshell.api.owner +import ifcopenshell.guid +import ifcopenshell.util.element +from typing import Union + + +def assign_pset( + file: ifcopenshell.file, + products: list[ifcopenshell.entity_instance], + pset: ifcopenshell.entity_instance, +) -> Union[ifcopenshell.entity_instance, None]: + """Assign property set to provided elements. + + This method can be used to make psets shared by multiple elements. + + :param products: Elements (or element types) to assign the pset to. + :param pset: Property set. + :return: None if `products` is empty or has only type elements. + IfcRelDefinesByProperties if `products` contains occurrences. + + Example: + + .. code:: python + + element = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") + ifcopenshell.api.pset.assign_pset(model, [element], pset) + # Pset is now assigned. + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {element} + + element1 = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") + element2 = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") + ifcopenshell.api.pset.assign_pset(model, [element1, element2], pset) + # Pset is now shared by multiple elements. + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {element, element1, element2} + + # Same for element types. + element_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType") + ifcopenshell.api.pset.assign_pset(model, [element_type], type_pset) + # Pset is now assigned to the type. + assert ifcopenshell.util.element.get_elements_by_pset(type_pset) == {element_type} + """ + is_ifc2x3 = file.schema == "IFC2X3" + + products_occurrences: set[ifcopenshell.entity_instance] = set() + products_types: set[ifcopenshell.entity_instance] = set() + for product in products: + if product.is_a("IfcTypeProduct"): + products_types.add(product) + else: + products_occurrences.add(product) + + rel = None + # Check occurrences using pset. + if products_occurrences: + rels = pset.PropertyDefinitionOf if is_ifc2x3 else pset.DefinesOccurrence + rel = next(iter(rels), None) + if rel is not None: + objs = set(rel.RelatedObjects) | products_occurrences + rel.RelatedObjects = list(objs) + else: + rel = file.create_entity( + "IfcRelDefinesByProperties", + **{ + "GlobalId": ifcopenshell.guid.new(), + "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), + "RelatedObjects": list(products_occurrences), + "RelatingPropertyDefinition": pset, + }, + ) + + for product in products_types: + psets = list(product.HasPropertySets or []) + product.HasPropertySets = psets + [pset] + + return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py index 797f0afba3..6de7ca5f44 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py @@ -167,6 +167,9 @@ def edit_pset( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self) -> None: self.update_pset_name() self.load_pset_template() @@ -378,7 +381,7 @@ class Usecase: properties.append(self.file.create_entity("IfcPropertySingleValue", **args)) return properties - def assign_new_properties(self, props: ifcopenshell.entity_instance) -> None: + def assign_new_properties(self, props: list[ifcopenshell.entity_instance]) -> None: if hasattr(self.settings["pset"], "HasProperties"): self.settings["pset"].HasProperties = props diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/edit_qto.py b/src/ifcopenshell-python/ifcopenshell/api/pset/edit_qto.py index fce082fd86..ef17b78153 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/edit_qto.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/edit_qto.py @@ -154,6 +154,9 @@ class Usecase: name = prop.Name if value is None: self.file.remove(prop) + elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict): + prop.Discrimination = value.get("Discrimination", prop.Discrimination) + ifcopenshell.api.pset.edit_qto(self.file, qto=prop, properties=value["HasQuantities"]) elif prop.is_a("IfcPhysicalSimpleQuantity"): value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value prop[3] = float(value) @@ -164,14 +167,21 @@ class Usecase: for name, value in self.settings["properties"].items(): if value is None: continue - property_type = self.get_canonical_property_type(name, value) - value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value - properties.append( - self.file.create_entity( - "IfcQuantity{}".format(property_type), - **{"Name": name, "{}Value".format(property_type): value}, + if isinstance(value, dict): + complex_qto = self.file.create_entity( + "IfcPhysicalComplexQuantity", Name=name, Discrimination=value["Discrimination"] + ) + properties.append(complex_qto) + ifcopenshell.api.pset.edit_qto(self.file, qto=complex_qto, properties=value["HasQuantities"]) + else: + property_type = self.get_canonical_property_type(name, value) + value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value + properties.append( + self.file.create_entity( + "IfcQuantity{}".format(property_type), + **{"Name": name, "{}Value".format(property_type): value}, + ) ) - ) return properties def extend_qto_with_new_properties(self, new_properties): diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/unassign_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/unassign_pset.py new file mode 100644 index 0000000000..24256f77fe --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/unassign_pset.py @@ -0,0 +1,80 @@ +# 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 +import ifcopenshell.api.owner +import ifcopenshell.guid +import ifcopenshell.util.element + + +def unassign_pset( + file: ifcopenshell.file, + products: list[ifcopenshell.entity_instance], + pset: ifcopenshell.entity_instance, +) -> None: + """Unassign property set from the provided elements. + + :param products: Elements (or element types) to assign the pset from. + :param pset: Property set. + + Example: + + .. code:: python + + element1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.pset.assign_pset(self.file, [element1, element2], pset) + + # Pset is now shared by 2 elements. + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {element1, element2} + + ifcopenshell.api.pset.unassign_pset(self.file, [element2], pset) + # Pset was unassigned from element2. + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {element1} + + """ + is_ifc2x3 = file.schema == "IFC2X3" + + products_occurrences: set[ifcopenshell.entity_instance] = set() + products_types: set[ifcopenshell.entity_instance] = set() + for product in products: + if product.is_a("IfcTypeProduct"): + products_types.add(product) + else: + products_occurrences.add(product) + + # Check occurrences using pset. + if products_occurrences: + rels = pset.PropertyDefinitionOf if is_ifc2x3 else pset.DefinesOccurrence + for rel in rels: + objs = set(rel.RelatedObjects) + if not any(p in objs for p in products_occurrences): + continue + objs.difference_update(products_occurrences) + if objs: + rel.RelatedObjects = list(objs) + else: + history = rel.OwnerHistory + file.remove(rel) + if history: + ifcopenshell.util.element.remove_deep2(file, history) + + for product in products_types: + psets = list(product.HasPropertySets) + psets.remove(pset) + product.HasPropertySets = psets or None diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py new file mode 100644 index 0000000000..051905b2b7 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py @@ -0,0 +1,95 @@ +# 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 +import ifcopenshell.api.owner +import ifcopenshell.api.pset +import ifcopenshell.guid +import ifcopenshell.util.element + + +def unshare_pset( + file: ifcopenshell.file, + products: list[ifcopenshell.entity_instance], + pset: ifcopenshell.entity_instance, +) -> list[ifcopenshell.entity_instance]: + """Copy a shared pset as linked only to the provided elements. + + Note that method will create a copy of the pset for each element provided. + + :param products: Elements (or element types) to link the pset to. + :param pset: Shared property set. + :return: List of copied property sets. + + Example: + + .. code:: python + + element1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.pset.assign_pset(self.file, [element1, element2], pset) + + # Pset is now shared by 2 elements. + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {element1, element2} + + new_psets = ifcopenshell.api.pset.unshare_pset(self.file, [element2], pset) + + # element2 was unassigned from the original pset. + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {element1} + new_pset = new_psets[0] + + # New pset was created and was assigned to element2. + assert new_pset != pset + assert ifcopenshell.util.element.get_elements_by_pset(new_pset) == {element2} + """ + + if not products: + raise Exception("No products provided.") + + # If pset has no other elements besides the provided products, + # then we skip the first product, so it won't get additional pset copy + # leaving the original pset orphaned. + pset_elements = ifcopenshell.util.element.get_elements_by_pset(pset) + products_original = products + + if set(products) == pset_elements: + products = products[1:] + + if not products: + raise Exception(f"Provided product is the only element to which pset is assigned: {products_original[0]}.") + + products_occurrences: set[ifcopenshell.entity_instance] = set() + products_types: set[ifcopenshell.entity_instance] = set() + for product in products: + if product.is_a("IfcTypeProduct"): + products_types.add(product) + else: + products_occurrences.add(product) + + ifcopenshell.api.pset.unassign_pset(file, products, pset) + + pset_copies: list[ifcopenshell.entity_instance] = [] + for product in products: + # No need to consider about profile/material properties since + # they are assigned to 1 element directly and therefore cannot be shared. + # Don't copy_deep to keep it light - edit_pset supports unsharing shared props. + pset_copy = ifcopenshell.util.element.copy(file, pset) + pset_copies.append(pset_copy) + ifcopenshell.api.pset.assign_pset(file, [product], pset_copy) + + return pset_copies diff --git a/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py b/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py index 3326f0b123..e9ac4b6891 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py +++ b/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py @@ -17,10 +17,17 @@ # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.geometry +import ifcopenshell.api.pset +import ifcopenshell.api.spatial +import ifcopenshell.api.type +import ifcopenshell.guid +import ifcopenshell.util.representation import ifcopenshell.util.type import ifcopenshell.util.schema import ifcopenshell.util.element -from typing import Optional +from typing import Optional, Union, Literal def reassign_class( @@ -46,15 +53,14 @@ def reassign_class( cannot be unambiguously derived, so you are required to manually check this. + 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. - :type ifc_class: str,optional :param predefined_type: In case you want to change the predefined type too. User defined types are also allowed, just type what you want. - :type predefined_type: str,optional :return: The newly modified product. - :rtype: ifcopenshell.entity_instance Example: @@ -80,38 +86,124 @@ def reassign_class( class Usecase: - def execute(self): - element = self.reassign_class(self.settings["product"], self.settings["ifc_class"]) + 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"] + + was_type_product_before = product.is_a("IfcTypeProduct") + schema = ifcopenshell.schema_by_name(self.file.schema) + is_type_product_after = schema.declaration_by_name(ifc_class)._is("IfcTypeProduct") + + if was_type_product_before == is_type_product_after: + return self.simple_reassignment(product, ifc_class, predefined_type) + + switch_type = "occurrence_to_type" if is_type_product_after else "type_to_occurrence" + + return self.switch_between_class_types( + product, + switch_type, + ifc_class, + predefined_type, + ) + + def switch_between_class_types( + self, + element: ifcopenshell.entity_instance, + switch_type: Literal["occurrence_to_type", "type_to_occurrence"], + ifc_class: str, + predefined_type: Union[str, None], + ) -> ifcopenshell.entity_instance: + + psets_to_reassign: list[ifcopenshell.entity_instance] = [] + + representations = list(ifcopenshell.util.representation.get_representations_iter(element)) + for rep in representations: + if switch_type == "type_to_occurrence": + rep = ifcopenshell.util.representation.resolve_representation(rep) + ifcopenshell.api.geometry.unassign_representation(self.file, product=element, representation=rep) + + if switch_type == "type_to_occurrence": + occurrences = ifcopenshell.util.element.get_types(element) + # Don't need to reassign as psets are linked to type directly, without rel. + psets_to_reassign = element.HasPropertySets or [] + + element = self.reassign_class(element, ifc_class, predefined_type) + ifcopenshell.api.type.unassign_type(self.file, occurrences) + + else: # occurrence_to_type + element_type = ifcopenshell.util.element.get_type(element) + # Handle element type. + if element_type: + ifcopenshell.api.type.unassign_type(self.file, [element]) + + # Handle containers and aggregates. + if ifcopenshell.util.element.get_container(element): + ifcopenshell.api.spatial.unassign_container(self.file, [element]) + elif ifcopenshell.util.element.get_aggregate(element): + ifcopenshell.api.aggregate.unassign_object(self.file, [element]) + + psets = ifcopenshell.util.element.get_psets(element) + for pset_name in psets: + pset = self.file.by_id(psets[pset_name]["id"]) + psets_to_reassign.append(pset) + ifcopenshell.api.pset.unassign_pset(self.file, [element], pset) + + element = self.reassign_class(element, ifc_class, predefined_type) + + # Reassign psets. + for pset in psets_to_reassign: + ifcopenshell.api.pset.assign_pset(self.file, [element], pset) + + # Reassign representations. + for rep in representations: + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + + # Keep IFC valid. + if switch_type == "type_to_occurrence" and representations: + ifcopenshell.api.geometry.edit_object_placement(self.file, product=element) + + return element + + def simple_reassignment( + self, + element: ifcopenshell.entity_instance, + ifc_class: str, + predefined_type: Union[str, None], + ) -> ifcopenshell.entity_instance: + element = self.reassign_class(element, ifc_class, predefined_type) if element.is_a("IfcTypeProduct"): - for occurrence in ifcopenshell.util.element.get_types(element) or []: - ifc_class = ifcopenshell.util.type.get_applicable_entities(self.settings["ifc_class"])[0] - self.reassign_class(occurrence, ifc_class) + for occurrence in ifcopenshell.util.element.get_types(element): + ifc_class_ = ifcopenshell.util.type.get_applicable_entities(ifc_class)[0] + self.reassign_class(occurrence, ifc_class_, predefined_type) else: element_type = ifcopenshell.util.element.get_type(element) if element_type: - ifc_class = ifcopenshell.util.type.get_applicable_types(self.settings["ifc_class"]) - if ifc_class and len(ifc_class) == 1: - element_type = self.reassign_class(element_type, ifc_class[0]) + ifc_class_ = next(iter(ifcopenshell.util.type.get_applicable_types(ifc_class))) + element_type = self.reassign_class(element_type, ifc_class_, predefined_type) ifc_class = element.is_a() - for occurrence in ifcopenshell.util.element.get_types(element_type) or []: + for occurrence in ifcopenshell.util.element.get_types(element_type): if occurrence == element: continue - self.reassign_class(occurrence, ifc_class) + self.reassign_class(occurrence, ifc_class, predefined_type) return element - def reassign_class(self, element, ifc_class): + def reassign_class( + self, element: ifcopenshell.entity_instance, ifc_class: str, predefined_type: Union[str, None] + ) -> ifcopenshell.entity_instance: element = ifcopenshell.util.schema.reassign_class(self.file, element, ifc_class) - if self.settings["predefined_type"] and hasattr(element, "PredefinedType"): + if predefined_type and hasattr(element, "PredefinedType"): try: - element.PredefinedType = self.settings["predefined_type"] + element.PredefinedType = predefined_type except: # PredefinedType wasn't in the respective enum, assume it's actually USERDEFINED # and set .ElementType / .ObjectType to the provided predefined type element.PredefinedType = "USERDEFINED" if element.is_a("IfcTypeProduct"): - element.ElementType = self.settings["predefined_type"] + element.ElementType = predefined_type else: - element.ObjectType = self.settings["predefined_type"] + element.ObjectType = predefined_type return element diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py b/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py index afa30cfb15..e1c92995be 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/assign_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 assign_representation_styles( @@ -112,6 +113,10 @@ def assign_representation_styles( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + results: list[ifcopenshell.entity_instance] + def execute(self): if not self.settings["styles"]: return [] @@ -121,10 +126,12 @@ class Usecase: replace_previous_same_type_style = self.settings["replace_previous_same_type_style"] for element in self.file.traverse(self.settings["shape_representation"]): - if not element.is_a("IfcShapeRepresentation"): + if not element.is_a("IfcShapeModel"): continue for item in element.Items: - if not item.is_a("IfcGeometricRepresentationItem"): + if not item.is_a("IfcGeometricRepresentationItem") and not item.is_a( + "IfcTopologicalRepresentationItem" + ): continue if self.settings["styles"]: # If there are more items than styles, fallback to using the last style diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py index 65fe7af288..bf10cd57ce 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py @@ -18,6 +18,7 @@ import ifcopenshell.api.style import ifcopenshell.util.element +from typing import Any def remove_style(file: ifcopenshell.file, style: ifcopenshell.entity_instance) -> None: @@ -26,9 +27,7 @@ def remove_style(file: ifcopenshell.file, style: ifcopenshell.entity_instance) - All of the presentation items of the style will also be removed. :param style: The IfcPresentationStyle to remove. - :type style: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -47,25 +46,28 @@ def remove_style(file: ifcopenshell.file, style: ifcopenshell.entity_instance) - class Usecase: - def execute(self): + file: ifcopenshell.file + settings: dict[str, Any] + + def execute(self) -> None: self.purge_styled_items(self.settings["style"]) for style in self.settings["style"].Styles or []: ifcopenshell.api.style.remove_surface_style(self.file, style=style) self.file.remove(self.settings["style"]) - def purge_styled_items(self, style): + def purge_styled_items(self, style: ifcopenshell.entity_instance) -> None: for inverse in self.file.get_inverse(style): if inverse.is_a("IfcStyledItem") and len(inverse.Styles) == 1: self.purge_styled_representations(inverse) self.file.remove(inverse) - def purge_styled_representations(self, styled_item): + def purge_styled_representations(self, styled_item: ifcopenshell.entity_instance) -> None: for inverse in self.file.get_inverse(styled_item): if inverse.is_a("IfcStyledRepresentation") and len(inverse.Items) == 1: self.purge_material_definition_representations(inverse) self.file.remove(inverse) - def purge_material_definition_representations(self, styled_representation): + def purge_material_definition_representations(self, styled_representation: ifcopenshell.entity_instance) -> None: for inverse in self.file.get_inverse(styled_representation): if inverse.is_a("IfcMaterialDefinitionRepresentation") and len(inverse.Representations) == 1: self.file.remove(inverse) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 774b5f9523..1e32e29570 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -88,7 +88,8 @@ def main( ) # this is required for serialization - geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + dimensionality = W.CURVES_SURFACES_AND_SOLIDS if settings.include_curves else W.SURFACES_AND_SOLIDS + geom_settings.set("dimensionality", dimensionality) geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE) geom_settings.set("apply-default-materials", True) @@ -128,6 +129,15 @@ def main( # it's up to user to keep them valid for the provided projects. if settings.drawing_guid or settings.drawing_object_type: if settings.drawing_guid: + found_guid = False + for f in files: + try: + f.by_guid(settings.drawing_guid) + found_guid = True + except: + pass + if not found_guid: + raise ValueError(f"Unable to find guid {settings.drawing_guid!r}") sr.setElevationRefGuid(settings.drawing_guid) elif settings.drawing_object_type: sr.setElevationRef(settings.drawing_object_type) diff --git a/src/ifcopenshell-python/ifcopenshell/express/__init__.py b/src/ifcopenshell-python/ifcopenshell/express/__init__.py index d7a0ceebc4..91aa35fd82 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/express/__init__.py @@ -16,9 +16,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +from __future__ import annotations import os import sys import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import schema_class d = os.path.abspath(os.path.dirname(__file__)) sys.path.append(d) @@ -30,8 +35,9 @@ if not os.path.exists(exp_parser_fn): subprocess.call([sys.executable, "bootstrap.py"], cwd=d, stdout=f) -def parse(fn): +def parse(fn: str) -> schema_class.SchemaClass: import express_parser import schema_class + mapping = express_parser.parse(fn) return schema_class.SchemaClass(mapping, schema_class.LateBoundSchemaInstantiator).code diff --git a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py index c93fdc21f5..9ff87d5ad0 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py +++ b/src/ifcopenshell-python/ifcopenshell/express/bootstrap.py @@ -204,7 +204,7 @@ for id in to_emit: statements.append("%s << %s" % (id, stmt)) if __name__ == "__main__": - print(""" + print(r""" # This file is generated by IfcOpenShell ifcexpressparser bootstrap.py from __future__ import annotations diff --git a/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py b/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py index b617036f8a..c49146ad33 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py +++ b/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py @@ -585,7 +585,7 @@ def process_local_variable(context): if ( context.parameter_type.generalized_types.general_aggregation_types.general_set_type ): - expr = re.sub("(\[[^\]]*\])", "express_set(\\1)", expr) + expr = re.sub(r"(\[[^\]]*\])", "express_set(\\1)", expr) return "%s = %s" % (str(context.variable_id).lower(), expr) else: diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 953693ba44..b8c6f86cf0 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -24,11 +24,30 @@ import zipfile import functools import ifcopenshell from pathlib import Path -from typing import Optional, Any, Union, Callable, Generator, Literal +from typing import Optional, Any, Union, Callable, Generator, Literal, TYPE_CHECKING from . import ifcopenshell_wrapper from .entity_instance import entity_instance +if TYPE_CHECKING: + import ifcopenshell.util.schema + +HEADER_FIELDS = { + "file_description": [ + "description", + "implementation_level", + ], + "file_name": [ + "name", + "time_stamp", + "author", + "organization", + "preprocessor_version", + "originating_system", + "authorization", + ], +} + class Transaction: def __init__(self, ifc_file): @@ -406,10 +425,10 @@ class file: return e @property - def schema(self) -> Literal["IFC2X3", "IFC4", "IFC4X3"]: + def schema(self) -> ifcopenshell.util.schema.IFC_SCHEMA: """General IFC schema version: IFC2X3, IFC4, IFC4X3.""" prefixes = ("IFC", "X", "_ADD", "_TC") - reg = "".join(f"(?P<{s}>{s}\d+)?" for s in prefixes) + reg = "".join(f"(?P<{s}>{s}\\d+)?" for s in prefixes) match = re.match(reg, self.wrapped_data.schema) version_tuple = tuple( map( @@ -515,18 +534,14 @@ class file: return [entity_instance(e, self) for e in self.wrapped_data.by_type_excl_subtypes(type)] def traverse( - self, inst: ifcopenshell.entity_instance, max_levels=None, breadth_first=False + self, inst: ifcopenshell.entity_instance, max_levels: Optional[int] = None, breadth_first: bool = False ) -> list[ifcopenshell.entity_instance]: """Get a list of all referenced instances for a particular instance including itself :param inst: The entity instance to get all sub instances - :type inst: ifcopenshell.entity_instance :param max_levels: How far deep to recursively fetch sub instances. None or -1 means infinite. - :type max_levels: None|int :param breadth_first: Whether to use breadth-first search, the default is depth-first. - :type max_levels: bool :returns: A list of ifcopenshell.entity_instance objects - :rtype: list[ifcopenshell.entity_instance] """ if max_levels is None: max_levels = -1 @@ -613,6 +628,11 @@ class file: def __iter__(self) -> Generator[ifcopenshell.entity_instance, None, None]: return iter(self[id] for id in self.wrapped_data.entity_names()) + def assign_header_from(self, other): + for k, vs in HEADER_FIELDS.items(): + for v in vs: + setattr(getattr(self.header, k), v, getattr(getattr(other.header, k), v)) + def write(self, path: "os.PathLike | str", format: Optional[str] = None, zipped: bool = False) -> None: """Write ifc model to file. diff --git a/src/ifcopenshell-python/ifcopenshell/geom/app.py b/src/ifcopenshell-python/ifcopenshell/geom/app.py index 6c873597cd..fa79fdb3ed 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/app.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/app.py @@ -22,6 +22,7 @@ import time import operator import functools import multiprocessing +import ifcopenshell.ifcopenshell_wrapper as W try: from OCC.Core import AIS @@ -522,8 +523,8 @@ class application(QtWidgets.QApplication): if setting is None: setting = settings() - setting.set(setting.INCLUDE_CURVES, True) - setting.set(setting.USE_PYTHON_OPENCASCADE, True) + setting.set("dimensionality", W.CURVES_SURFACES_AND_SOLIDS) + setting.set("use-python-opencascade", True) self.signals = geometry_creation_signals() thread = self.thread = geometry_creation_thread(self.signals, setting, f) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 22bc31ca94..1773275bea 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -101,6 +101,9 @@ SETTING = Literal[ "piecewise-step-param", "use-python-opencascade", "no-parallel-mapping", + "triangulation-type", + "model-rotation", + "model-offset", ] SERIALIZER_SETTING = Literal[ "use-element-names", @@ -365,7 +368,7 @@ def create_shape( """ Return a geometric representation from STEP-based IFCREPRESENTATIONSHAPE or - Return an OpenCASCADE BRep if settings.USE_PYTHON_OPENCASCADE == True + Return an OpenCASCADE BRep if 'use-python-opencascade' is True Note that in Python, you must store a reference to the element returned by this function to prevent garbage collection when you access its children. See #1124. @@ -391,7 +394,7 @@ def create_shape( .. code:: python settings = ifcopenshell.geom.settings() - settings.set(settings.USE_PYTHON_OPENCASCADE, True) + settings.set("use-python-opencascade", True) ifc_file = ifcopenshell.open(file_path) products = ifc_file.by_type("IfcProduct") @@ -437,6 +440,8 @@ def consume_iterator( break +# Overloads need to cover different return types +# based on `with_progress` argument. @overload def iterate( settings: settings, @@ -444,8 +449,10 @@ def iterate( num_threads: int = 1, include: Optional[Union[list[entity_instance], list[str]]] = None, exclude: Optional[Union[list[entity_instance], list[str]]] = None, + *, with_progress: Literal[False] = False, - cache: Optional[serializers.hdf5] = None, + cache: Optional[str] = None, + serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", ) -> Generator[IteratorOutput, None, None]: ... @overload @@ -455,8 +462,10 @@ def iterate( num_threads: int = 1, include: Optional[Union[list[entity_instance], list[str]]] = None, exclude: Optional[Union[list[entity_instance], list[str]]] = None, + *, with_progress: Literal[True] = True, - cache: Optional[serializers.hdf5] = None, + cache: Optional[str] = None, + serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", ) -> Generator[tuple[int, IteratorOutput], None, None]: ... @overload @@ -466,8 +475,10 @@ def iterate( num_threads: int = 1, include: Optional[Union[list[entity_instance], list[str]]] = None, exclude: Optional[Union[list[entity_instance], list[str]]] = None, + *, with_progress: bool = False, - cache: Optional[serializers.hdf5] = None, + cache: Optional[str] = None, + serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", ) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: ... def iterate( @@ -476,13 +487,21 @@ def iterate( num_threads: int = 1, include: Optional[Union[list[entity_instance], list[str]]] = None, exclude: Optional[Union[list[entity_instance], list[str]]] = None, + *, with_progress: bool = False, - cache: Optional[serializers.hdf5] = None, + cache: Optional[str] = None, + serializer_settings: Optional[serializer_settings] = None, geometry_library: GEOMETRY_LIBRARY = "opencascade", ) -> Generator[Union[IteratorOutput, tuple[int, IteratorOutput]], None, None]: + """Get a geometry iterator for the provided file. + + :param cache: .h5 cache filepath (might not exist, will be created). + :param serializer_settings: Settings for cache serializer. Required if `cache` is provided. + """ it = iterator(settings, file_or_filename, num_threads, include, exclude, geometry_library) if cache: - hdf5_cache = serializers.hdf5(cache, settings) + assert serializer_settings, "`serializer_settings` argument is not optional if `cache` is provided." + hdf5_cache = serializers.hdf5(cache, settings, serializer_settings) it.set_cache(hdf5_cache) yield from consume_iterator(it, with_progress=with_progress) diff --git a/src/ifcopenshell-python/ifcopenshell/sql.py b/src/ifcopenshell-python/ifcopenshell/sql.py index 343a09561a..a77b5a85af 100644 --- a/src/ifcopenshell-python/ifcopenshell/sql.py +++ b/src/ifcopenshell-python/ifcopenshell/sql.py @@ -1,17 +1,57 @@ -try: - import re - import json +# 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 . +from __future__ import annotations +import re +import json +import ifcopenshell +import ifcopenshell.util.attribute +import ifcopenshell.util.schema +from pathlib import Path +from typing import Any, NoReturn, Union, Optional, TYPE_CHECKING +from . import ifcopenshell_wrapper +from .file import file +from .entity_instance import entity_instance - from .file import file - from . import ifcopenshell_wrapper - from .entity_instance import entity_instance +if TYPE_CHECKING: + import sqlite3 + +try: + import sqlite3 except ImportError as e: print(f"No SQL support: {e}") class sqlite(file): - def __init__(self, filepath): - import sqlite3 + schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4" + + def __init__(self, filepath: str): + """ + Open existing sqlite IFC database. + + To create a new database from IFC file consider using Ifc2Sql IfcPatch: + + https://docs.ifcopenshell.org/autoapi/ifcpatch/recipes/Ifc2Sql/index.html + + :param filepath: Path to sqlite database. + """ + + if not Path(filepath).exists(): + raise FileNotFoundError(f"File doesn't exist: {filepath}") self.wrapped_data = None self.history_size = 64 @@ -42,23 +82,22 @@ class sqlite(file): assert False, "SQLite schema not supported." self.schema = row[1] - self.ifc_schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(self.schema) + self.ifc_schema = ifcopenshell.schema_by_name(self.schema) self.cursor.execute("SELECT ifc_id, ifc_class FROM id_map") - self.id_map = {} - self.class_map = {} - self.entity_cache = {} + self.id_map: dict[int, str] = {} + self.class_map: dict[str, list[int]] = {} + self.entity_cache: dict[int, sqlite_entity] = {} for row in self.cursor.fetchall(): - self.id_map[row[0]] = row[1] - self.class_map.setdefault(row[1], []).append(row[0]) + ifc_id, ifc_class = row + self.id_map[ifc_id] = ifc_class + self.class_map.setdefault(ifc_class, []).append(ifc_id) self.preprocess_schema() - def preprocess_schema(self): - import ifcopenshell.util.schema - + def preprocess_schema(self) -> None: self.ifc_class_subtypes = {} - self.ifc_class_attributes = {} + self.ifc_class_attributes: dict[str, dict[str, ifcopenshell_wrapper.attribute]] = {} self.ifc_class_inverse_attributes = {} self.ifc_class_references = {} self.ifc_class_inverses = {} @@ -99,13 +138,14 @@ class sqlite(file): self.ifc_class_references[declaration.name()] = {"entity": entity, "entity_list": entity_list} - def clear_cache(self): + def clear_cache(self) -> None: self.entity_cache = {} - def create_entity(self, type, *args, **kawrgs): - assert False + def create_entity(self, type, *args, **kawrgs) -> NoReturn: + """Not supported for sqlite database.""" + assert False, "Not supported for sqlite database." - def by_id(self, id): + def by_id(self, id: int) -> Union[sqlite_entity, None]: entity = self.entity_cache.get(id, None) if entity: return entity @@ -114,15 +154,16 @@ class sqlite(file): entity = sqlite_entity(id, ifc_class, self) self.entity_cache[id] = entity return entity - self.cursor.execute("SELECT ifc_id, ifc_class FROM id_map LIMIT 1") + self.cursor.execute("SELECT ifc_id, ifc_class FROM id_map WHERE ifc_id = ? LIMIT 1", (id,)) row = self.cursor.fetchone() if row: - self.id_map[row[0]] = row[1] + _, ifc_class = row + self.id_map[id] = ifc_class entity = sqlite_entity(id, ifc_class, self) self.entity_cache[id] = entity return entity - def by_type(self, type, include_subtypes=True): + def by_type(self, type: str, include_subtypes: bool = True) -> list[sqlite_entity]: # TODO use cached subtypes import ifcopenshell.util.schema @@ -142,7 +183,9 @@ class sqlite(file): rows = self.cursor.fetchall() return [self.by_id(r[0]) for r in rows] - def traverse(self, inst, max_levels=None, breadth_first=False): + def traverse( + self, inst: sqlite_entity, max_levels: Optional[int] = None, breadth_first: bool = False + ) -> list[sqlite_entity]: results = [inst] queue = [inst] while queue: @@ -170,7 +213,9 @@ class sqlite(file): # print('traverse results', results) return results - def get_inverse(self, inst, allow_duplicate=False, with_attribute_indices=False): + def get_inverse( + self, inst: sqlite_entity, allow_duplicate: bool = False, with_attribute_indices: bool = False + ) -> set[sqlite_entity]: query = ( f"SELECT inverses FROM {inst.sqlite_wrapper.ifc_class} WHERE `ifc_id` = {inst.sqlite_wrapper.id} LIMIT 1" ) @@ -180,7 +225,7 @@ class sqlite(file): return set() return {self.by_id(e) for e in json.loads(row[0])} - def is_entity_list(self, attribute): + def is_entity_list(self, attribute: ifcopenshell_wrapper.attribute) -> bool: attribute = str(attribute.type_of_attribute()) if (attribute.startswith("", attribute): @@ -223,9 +268,15 @@ class sqlite(file): } return {"shapes": shapes, "geometry": geometry} + def __del__(self) -> None: + # Override to avoid clean up data unrelated to sqlite file. + pass + class sqlite_entity(entity_instance): - def __init__(self, id, ifc_class, file=None): + sqlite_wrapper: sqlite_wrapper + + def __init__(self, id: int, ifc_class: str, file: sqlite = None): if not ifc_class: print(id, ifc_class, file) assert False @@ -234,23 +285,23 @@ class sqlite_entity(entity_instance): super(entity_instance, self).__setattr__("wrapped_data", e) super(entity_instance, self).__setattr__("sqlite_wrapper", s) - def id(self): + def id(self) -> int: return self.sqlite_wrapper.id - def __del__(self): + def __del__(self) -> None: pass - def __getitem__(self, key): + def __getitem__(self, key: int) -> Any: return self.__getattr__(list(self.sqlite_wrapper.attributes.keys())[key]) - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Any) -> None: # query = f"UPDATE `{self.sqlite_wrapper.ifc_class}` SET `{key}`='' WHERE `ifc_id` = {self.sqlite_wrapper.id}" query = f"UPDATE `{self.sqlite_wrapper.ifc_class}` SET `{key}` = ? WHERE ifc_id = {self.sqlite_wrapper.id}" self.sqlite_wrapper.file.cursor.execute(query, (value,)) self.sqlite_wrapper.file.db.commit() self.sqlite_wrapper.attribute_cache = {} - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: # print("*" * 100) # print("GETATTR", self.sqlite_wrapper.id, self.sqlite_wrapper.ifc_class, name) @@ -343,7 +394,7 @@ class sqlite_entity(entity_instance): return value2 return value - def __eq__(self, other): + def __eq__(self, other: sqlite_entity) -> bool: if not isinstance(self, type(other)): return False elif None in (self.sqlite_wrapper.file, other.sqlite_wrapper.file): @@ -352,11 +403,46 @@ class sqlite_entity(entity_instance): return self.sqlite_wrapper.id == other.sqlite_wrapper.id assert False # not implemented - def __hash__(self): + def __repr__(self): + sqlite_wrapper = self.sqlite_wrapper + attribute_cache = sqlite_wrapper.attribute_cache + attr_strs: list[str] = [] + + if not attribute_cache: + self.__getitem__(0) # This will get all attributes + + def serialize_attr_value(value: Any) -> str: + if value is None: + attr_str = "$" + # Enums are not represented correctly but that should do. + elif isinstance(value, str): + attr_str = f"'{value}'" + elif isinstance(value, (int, float)): + attr_str = str(value) + elif isinstance(value, sqlite_entity): + attr_str = f"#{value.sqlite_wrapper.id}" + elif isinstance(value, entity_instance): + attr_str = str(value) + elif isinstance(value, tuple): + attr_str = ",".join(serialize_attr_value(v) for v in value) + attr_str = f"({attr_str})" + else: + attr_str = f"-" + return attr_str + + for attr_name in sqlite_wrapper.attributes: + value = attribute_cache[attr_name] + attr_strs.append(serialize_attr_value(value)) + attr_str = ",".join(attr_strs) + return f"#{sqlite_wrapper.id}={sqlite_wrapper.ifc_class.upper()}({attr_str});" + + def __hash__(self) -> int: if self.sqlite_wrapper.id: return hash((self.sqlite_wrapper.id, self.sqlite_wrapper.file.filepath)) - def get_info(self, include_identifier=True, recursive=False, return_type=dict, ignore=(), scalar_only=False): + def get_info( + self, include_identifier=True, recursive=False, return_type=dict, ignore=(), scalar_only=False + ) -> dict[str, Any]: info = {"id": self.sqlite_wrapper.id, "type": self.sqlite_wrapper.ifc_class} if not self.sqlite_wrapper.attribute_cache: self.__getitem__(0) # This will get all attributes @@ -365,14 +451,14 @@ class sqlite_entity(entity_instance): class sqlite_wrapper: - def __init__(self, id, ifc_class, file): + def __init__(self, id: int, ifc_class: str, file: sqlite): self.id = id self.ifc_class = ifc_class self.file = file self.attributes = self.file.ifc_class_attributes[self.ifc_class] self.inverse_attributes = self.file.ifc_class_inverse_attributes[self.ifc_class] - self.attribute_cache = {} + self.attribute_cache: dict[str, Any] = {} self.inverse_attribute_cache = {} - def __repr__(self): - return "todo" + def __repr__(self) -> str: + return f"sqlite_wrapper '#{self.id}={self.ifc_class}(...)'" diff --git a/src/ifcopenshell-python/ifcopenshell/stream.py b/src/ifcopenshell-python/ifcopenshell/stream.py index 01c5ce1ca9..999fd4abea 100644 --- a/src/ifcopenshell-python/ifcopenshell/stream.py +++ b/src/ifcopenshell-python/ifcopenshell/stream.py @@ -1,14 +1,39 @@ +# 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 . + +from __future__ import annotations + try: + import os import re + import ifcopenshell.util.attribute import ifcopenshell.util.schema from .file import file from . import ifcopenshell_wrapper from .entity_instance import entity_instance from lark import Lark, Transformer + from typing import Any, NoReturn, Union, Optional class StreamTransformer(Transformer): + file: file + def string(self, items): return str(items[0])[1:-1] @@ -62,7 +87,9 @@ try: return (int(items[0]), str(items[1]), items[2]) class stream(file): - def __init__(self, filepath): + schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4" + + def __init__(self, filepath: str): self.wrapped_data = None self.history_size = 64 self.history = [] @@ -72,13 +99,12 @@ try: self.filepath = filepath self.file = open(filepath, "r") - self.id_map = {} - self.class_map = {} - self.id_offset = {} - self.schema = "IFC4" + self.id_map: dict[int, str] = {} + self.class_map: dict[str, list[int]] = {} + self.id_offset: dict[int, int] = {} self.reference_pattern = re.compile(r"#(\d+)") - self.entity_cache = {} - self.inverses = {} + self.entity_cache: dict[int, stream_entity] = {} + self.inverses: dict[int, list[int]] = {} # common.INT doesn't support negative integers. grammar = r""" @@ -131,6 +157,7 @@ try: exclude = set() offset = 0 + newline_character = len(os.linesep) for line in self.file: line = line.strip() if line.startswith("#"): @@ -139,7 +166,7 @@ try: ifc_class = ifc_class.strip() if ifc_class in exclude: - offset += len(line) + 1 # +1 for the newline character + offset += len(line) + newline_character continue for reference_id in self.reference_pattern.findall(line[1:]): @@ -150,18 +177,18 @@ try: self.id_offset[step_id] = offset elif line.startswith("FILE_SCHEMA"): self.schema = line.split("'")[1] - self.ifc_schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(self.schema) + self.ifc_schema = ifcopenshell.schema_by_name(self.schema) for ifc_class in exclude_classes: declaration = self.ifc_schema.declaration_by_name(ifc_class) exclude.update([st.name().upper() for st in ifcopenshell.util.schema.get_subtypes(declaration)]) - offset += len(line) + 1 # +1 for the newline character + offset += len(line) + newline_character self.preprocess_schema() - def preprocess_schema(self): + def preprocess_schema(self) -> None: self.ifc_class_names = {} self.ifc_class_subtypes = {} - self.ifc_class_attributes = {} + self.ifc_class_attributes: dict[str, dict[str, ifcopenshell_wrapper.attribute]] = {} self.ifc_class_inverse_attributes = {} self.ifc_class_references = {} self.ifc_class_inverses = {} @@ -201,13 +228,14 @@ try: self.ifc_class_references[declaration.name()] = {"entity": entity, "entity_list": entity_list} - def clear_cache(self): + def clear_cache(self) -> None: self.entity_cache = {} - def create_entity(self, type, *args, **kawrgs): - assert False + def create_entity(self, type, *args, **kawrgs) -> NoReturn: + """Not supported during streaming.""" + assert False, "Not supported during streaming." - def by_id(self, id): + def by_id(self, id: int) -> Union[stream_entity, None]: entity = self.entity_cache.get(id, None) if entity: return entity @@ -217,14 +245,16 @@ try: self.entity_cache[id] = entity return entity - def by_type(self, type, include_subtypes=True): + def by_type(self, type: str, include_subtypes: bool = True) -> list[stream_entity]: results = [] subtypes = self.ifc_class_subtypes[type] if include_subtypes else self.ifc_class_subtypes[type][0:1] for subtype in subtypes: results.extend([self.by_id(i) for i in self.class_map.get(subtype.name().upper(), [])]) return results - def traverse(self, inst, max_levels=None, breadth_first=False): + def traverse( + self, inst: stream_entity, max_levels: Optional[int, None] = None, breadth_first: bool = False + ) -> list[stream_entity]: results = [inst] queue = [inst] while queue: @@ -242,10 +272,12 @@ try: return results - def get_inverse(self, inst, allow_duplicate=False, with_attribute_indices=False): + def get_inverse( + self, inst: stream_entity, allow_duplicate: bool = False, with_attribute_indices: bool = False + ) -> tuple[stream_entity, ...]: return {self.by_id(e) for e in self.inverses.get(inst.stream_wrapper.id, [])} - def is_entity_list(self, attribute): + def is_entity_list(self, attribute: ifcopenshell_wrapper.attribute) -> bool: attribute = str(attribute.type_of_attribute()) if (attribute.startswith("", attribute): @@ -254,8 +286,14 @@ try: return True return False + def __del__(self) -> None: + # Override to avoid clean up unrelated to stream file. + pass + class stream_entity(entity_instance): - def __init__(self, id, ifc_class, file=None): + stream_wrapper: stream_wrapper + + def __init__(self, id: int, ifc_class: str, file: stream = None): if not ifc_class: print(id, ifc_class, file) assert False @@ -264,27 +302,25 @@ try: super(entity_instance, self).__setattr__("wrapped_data", e) super(entity_instance, self).__setattr__("stream_wrapper", s) - def id(self): + def id(self) -> int: return self.stream_wrapper.id - def __repr__(self): + def __repr__(self) -> str: offset = self.stream_wrapper.file.id_offset[self.stream_wrapper.id] self.stream_wrapper.file.file.seek(offset) return self.stream_wrapper.file.file.readline().strip() - def __del__(self): + def __del__(self) -> None: pass - def __getitem__(self, key): + def __getitem__(self, key: int) -> Any: return self.__getattr__(list(self.stream_wrapper.attributes.keys())[key]) - def __setattr__(self, key, value): - query = f"UPDATE `{self.stream_wrapper.ifc_class}` SET `{key}` = ? WHERE ifc_id = {self.stream_wrapper.id}" - self.stream_wrapper.file.cursor.execute(query, (value,)) - self.stream_wrapper.file.db.commit() - self.stream_wrapper.attribute_cache = {} + def __setattr__(self, key: str, value: int) -> NoReturn: + """Not supported during streaming.""" + assert False, "Not supported during streaming." - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: INVALID, FORWARD, INVERSE = range(3) attr_cat = self.wrapped_data.get_attribute_category(name) if attr_cat == FORWARD: @@ -338,7 +374,7 @@ try: "entity instance of type '%s' has no attribute '%s'" % (self.wrapped_data.is_a(True), name) ) - def __eq__(self, other): + def __eq__(self, other: stream_entity) -> bool: if not isinstance(self, type(other)): return False elif None in (self.stream_wrapper.file, other.stream_wrapper.file): @@ -347,11 +383,13 @@ try: return self.stream_wrapper.id == other.stream_wrapper.id assert False # not implemented - def __hash__(self): + def __hash__(self) -> int: if self.stream_wrapper.id: return hash((self.stream_wrapper.id, self.stream_wrapper.file.filepath)) - def get_info(self, include_identifier=True, recursive=False, return_type=dict, ignore=(), scalar_only=False): + def get_info( + self, include_identifier=True, recursive=False, return_type=dict, ignore=(), scalar_only=False + ) -> dict[str, Any]: info = {"id": self.stream_wrapper.id, "type": self.stream_wrapper.ifc_class} if not self.stream_wrapper.attribute_cache: self.__getitem__(0) # This will get all attributes @@ -359,17 +397,17 @@ try: return info class stream_wrapper: - def __init__(self, id, ifc_class, file): + def __init__(self, id: int, ifc_class: str, file: stream): self.id = id self.ifc_class = ifc_class self.file = file self.attributes = self.file.ifc_class_attributes[self.ifc_class] self.inverse_attributes = self.file.ifc_class_inverse_attributes[self.ifc_class] - self.attribute_cache = {} + self.attribute_cache: dict[str, Any] = {} self.inverse_attribute_cache = {} - def __repr__(self): - return "todo" + def __repr__(self) -> str: + return f"stream_wrapper '#{self.id}={self.ifc_class}(...)'" except ImportError as e: import sys diff --git a/src/ifcopenshell-python/ifcopenshell/util/attribute.py b/src/ifcopenshell-python/ifcopenshell/util/attribute.py index 2fa47589c1..b7a241c5f6 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/attribute.py +++ b/src/ifcopenshell-python/ifcopenshell/util/attribute.py @@ -55,9 +55,9 @@ def get_primitive_type( return "binary" -def get_enum_items(attribute): +def get_enum_items(attribute: ifcopenshell_wrapper.attribute) -> tuple[str, ...]: return attribute.type_of_attribute().declared_type().enumeration_items() -def get_select_items(attribute): +def get_select_items(attribute: ifcopenshell_wrapper.attribute) -> tuple[ifcopenshell_wrapper.entity, ...]: return attribute.type_of_attribute().declared_type().select_list() diff --git a/src/ifcopenshell-python/ifcopenshell/util/classification.py b/src/ifcopenshell-python/ifcopenshell/util/classification.py index 42208a90e7..5d095a4052 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/classification.py +++ b/src/ifcopenshell-python/ifcopenshell/util/classification.py @@ -65,3 +65,23 @@ def get_inherited_references(reference: Optional[ifcopenshell.entity_instance]) results.append(reference) reference = reference.ReferencedSource return results + + +def get_classification_data(file: ifcopenshell.file) -> Optional[tuple[list[dict], str]]: + if not file or not file.by_type("IfcClassification"): + return [], "" + classification = file.by_type("IfcClassification")[0] + classification_name = classification.Name + + def process_references(reference): + data = reference.get_info() + del data["ReferencedSource"] + data["referenced_source"] = reference.ReferencedSource.id() if reference.ReferencedSource else None + data["has_references"] = bool(reference.HasReferences) + data["references"] = ( + [process_references(ref) for ref in reference.HasReferences] if reference.HasReferences else [] + ) + return data + + classification_data = [process_references(reference) for reference in classification.HasReferences] + return classification_data, classification_name diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index d9ce6f874f..cbb19dd1a3 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -19,7 +19,10 @@ import lark import ifcopenshell from typing import Optional, Union, Literal, Generator, Any - +import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper +from ifcopenshell.util.doc import get_predefined_type_doc +from ifcopenshell.util.element import get_psets +from ifcopenshell.util.unit import get_unit_symbol arithmetic_operator_symbols = {"ADD": "+", "DIVIDE": "/", "MULTIPLY": "*", "SUBTRACT": "-"} symbol_arithmetic_operators = {"+": "ADD", "/": "DIVIDE", "*": "MULTIPLY", "-": "SUBTRACT"} @@ -239,11 +242,94 @@ def get_cost_item_assignments( if not is_deep: return get_cost_assignments_by_type(cost_item, filter_by_type) else: - return [ + current_assignments = get_cost_assignments_by_type(cost_item, filter_by_type) + nested_assignments = [ product for nested_cost_item in get_all_nested_cost_items(cost_item) for product in get_cost_assignments_by_type(nested_cost_item, filter_by_type) ] + return current_assignments + nested_assignments + + +def get_cost_values(cost_item: ifcopenshell.entity_instance) -> list[dict[str, str]]: + results = [] + for cost_value in cost_item.CostValues or []: + label = "{0:.2f}".format(calculate_applied_value(cost_item, cost_value)) + label += " = {}".format(serialise_cost_value(cost_value)) + unit_data = {"value_component": None, "unit_component": None, "unit_symbol": ""} + if cost_value.UnitBasis: + data = cost_value.UnitBasis.get_info() + unit_data["value_component"] = data["ValueComponent"].wrappedValue + unit_data["unit_component"] = data["UnitComponent"].id() + unit_data["unit_symbol"] = get_unit_symbol(cost_value.UnitBasis.UnitComponent) + + results.append( + { + "id": cost_value.id(), + "label": label, + "name": cost_value.Name, + "category": cost_value.Category, + "applied_value": ( + get_primitive_applied_value(cost_value.AppliedValue) if cost_value.AppliedValue else None + ), + "unit_data": unit_data, + } + ) + return results + + +def get_cost_schedule_types(file): + schema: ifcopenshell_wrapper.schema_definition = ifcopenshell_wrapper.schema_by_name(file.schema) + results = [] + declaration = schema.declaration_by_name("IfcCostSchedule") + version = file.schema_identifier + for attribute in declaration.attributes(): + if attribute.name() == "PredefinedType": + for enumeration in attribute.type_of_attribute().declared_type().enumeration_items(): + results.append( + { + "name": enumeration, + "description": get_predefined_type_doc(version, "IfcCostSchedule", enumeration), + } + ) + break + return results + + +def get_product_quantity_names(elements: list[ifcopenshell.entity_instance]) -> list[str]: + names = set() + for element in elements or []: + potential_names = set() + qtos = get_psets(element, qtos_only=True) + for qset, quantities in qtos.items(): + potential_names.update(quantities.keys()) + names = names.intersection(potential_names) if names else potential_names + return [n for n in names if n != "id"] + + +def get_cost_schedule(cost_item: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Returns the cost schedule of a cost item.""" + for rel in cost_item.HasAssignments or []: + if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcCostSchedule"): + return rel.RelatingControl + for rel in cost_item.Nests or []: + return get_cost_schedule(rel.RelatingObject) + + +def get_cost_rate( + file: ifcopenshell_wrapper.file, cost_item: ifcopenshell.entity_instance +) -> Optional[ifcopenshell.entity_instance]: + """Returns the cost rate of a cost item.""" + # There is no direct relationship between a cost item and a cost rate in IFC, so we need to infer it, based on the assumption that cost_rate.CostValues == cost_item.CostValues. + if get_cost_schedule(cost_item).PredefinedType == "SCHEDULEOFRATES": + return None # Cost item is already a cost rate + if cost_item.CostValues: + potential_rates = file.get_inverse(cost_item.CostValues[0]) + for potential_rate in potential_rates: + schedule = get_cost_schedule(potential_rate) + if schedule.PredefinedType == "SCHEDULEOFRATES": + return potential_rate + return None class CostValueUnserialiser: diff --git a/src/ifcopenshell-python/ifcopenshell/util/doc.py b/src/ifcopenshell-python/ifcopenshell/util/doc.py index 2753d7d63b..b444966068 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/doc.py +++ b/src/ifcopenshell-python/ifcopenshell/util/doc.py @@ -60,7 +60,7 @@ IFC4x3_SPEC_URL_TEMPLATE = "https://ifc43-docs.standards.buildingsmart.org/IFC/R # child -> description # note: in IFC4x3 there is no children[] for properties -SUPPORTED_SCHEMA = Literal["IFC2X3", "IFC4", "IFC4X3"] +SUPPORTED_SCHEMA = ifcopenshell.util.schema.IFC_SCHEMA SCHEMA_FILES: dict[SUPPORTED_SCHEMA, dict] = { "IFC2X3": { "entities": BASE_MODULE_PATH / "schema/ifc2x3_entities.json", diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 6024cfc347..3859b21810 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -76,17 +76,29 @@ def get_pset( """ pset = None type_pset = None + ifc_file = element.file + is_ifc2x3 = ifc_file.schema == "IFC2X3" if element.is_a("IfcTypeObject"): for definition in element.HasPropertySets or []: if definition.Name == name: pset = definition break - elif element.is_a("IfcMaterialDefinition") or element.is_a("IfcProfileDef"): - for definition in element.HasProperties or []: - if definition.Name == name: - pset = definition - break + elif ( + (is_ifc2x3_material := (is_ifc2x3 and element.is_a("IfcMaterial"))) + or element.is_a("IfcMaterialDefinition") + or element.is_a("IfcProfileDef") + ): + if is_ifc2x3_material: + for definition in ifc_file.by_type("IfcExtendedMaterialProperties"): + if definition.Material == element and definition.Name == name: + pset = definition + break + else: + for definition in element.HasProperties or []: + if definition.Name == name: + pset = definition + break elif (is_defined_by := getattr(element, "IsDefinedBy", None)) is not None: # other IfcObjectDefinition if should_inherit: @@ -101,7 +113,11 @@ def get_pset( break if pset: - if psets_only and not pset.is_a("IfcPropertySet"): + if ( + psets_only + and not pset.is_a("IfcPropertySet") + and not (is_ifc2x3 and pset.is_a("IfcExtendedMaterialProperties")) + ): pset = None elif qtos_only and not pset.is_a("IfcElementQuantity"): pset = None @@ -221,6 +237,9 @@ def get_property_definition( elif ifc_class == "IfcMaterialProperties" or ifc_class == "IfcProfileProperties": # IfcExtendedProperties return get_property(definition.Properties, prop, verbose=verbose) + elif ifc_class == "IfcExtendedMaterialProperties": + # IFC2X3. + return get_property(definition.ExtendedProperties, prop, verbose=verbose) else: # Entity introduced in IFC4 # definition.is_a('IfcPreDefinedPropertySet'): @@ -422,6 +441,25 @@ def get_properties( return results +def get_elements_by_pset(pset: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]: + """Retrieve the elements (or element types) that are using the provided property set.""" + is_ifc2x3 = pset.file.schema == "IFC2X3" + elements = set() + if pset.is_a("IfcPropertySet") or pset.is_a("IfcElementQuantity"): + rels = pset.PropertyDefinitionOf if is_ifc2x3 else pset.DefinesOccurrence + for rel in rels: + elements.update(rel.RelatedObjects) + for element_type in pset.DefinesType: + elements.add(element_type) + elif pset.is_a("IfcProfileProperties"): + elements.add(pset.ProfileDefinition) + elif pset.is_a("IfcMaterialProperties"): + elements.add(pset.Material) + else: + raise Exception(f"Unexpected pset type: '{pset.is_a()}' ({pset}).") + return elements + + def get_predefined_type(element: ifcopenshell.entity_instance) -> str: """Retrieves the PrefefinedType attribute of an element. @@ -668,18 +706,15 @@ def get_styles(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entit # since we have entity_instance.file, so we can deprecate it. def get_elements_by_material( ifc_file: ifcopenshell.file, material: ifcopenshell.entity_instance -) -> list[ifcopenshell.entity_instance]: +) -> set[ifcopenshell.entity_instance]: """Retrieves the elements related to a material. This includes elements using the material as part of a material set or set usage. :param ifc_file: The IFC file - :type ifc_file: ifcopenshell.file :param material: The IFC Material entity - :type material: ifcopenshell.entity_instance - :return: A list of elements using the to the material - :rtype: list[ifcopenshell.entity_instance] + :return: A set of elements using the to the material Example: @@ -947,7 +982,7 @@ def get_structure_referenced_elements(structure: ifcopenshell.entity_instance) - return referenced -def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True) -> list[ifcopenshell.entity_instance]: +def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True) -> set[ifcopenshell.entity_instance]: """ Retrieves all subelements of an element based on the spatial decomposition hierarchy. This includes all subspaces and elements contained in subspaces, @@ -966,29 +1001,29 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True) decomposition = ifcopenshell.util.element.get_decomposition(element) """ queue = [element] - results = [] + results = set() while queue: element = queue.pop() for rel in getattr(element, "ContainsElements", []): related = rel.RelatedElements queue.extend(related) - results.extend(related) + results.update(related) for rel in getattr(element, "IsDecomposedBy", []): related = rel.RelatedObjects queue.extend(related) - results.extend(related) + results.update(related) for rel in getattr(element, "HasOpenings", []): related = rel.RelatedOpeningElement queue.append(related) - results.append(related) + results.add(related) for rel in getattr(element, "HasFillings", []): related = rel.RelatedBuildingElement queue.append(related) - results.append(related) + results.add(related) for rel in getattr(element, "IsNestedBy", []): related = rel.RelatedObjects queue.extend(related) - results.extend(related) + results.update(related) if not is_recursive: break return results @@ -1595,3 +1630,27 @@ def copy_deep( else: new[i] = attribute return new + + +def has_property(product: ifcopenshell.entity_instance, property_name: str) -> bool: + """ + Check if a product has a property with a given name. + + :param product: The IFC product + :type product: ifcopenshell.entity_instance + :param property_name: The property name + :type property_name: str + :return: True if the product has the property, False otherwise + :rtype: bool + + Example: + + .. code:: python + + product = file.by_type("IfcWall")[0] + has_property = ifcopenshell.util.element.has_property(product, "NetArea") + """ + if not property_name: + return True + qtos = get_psets(product, qtos_only=True) + return any(property_name in quantities.keys() for quantities in qtos.values()) diff --git a/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py b/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py index 813767b619..1de000fa4e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py +++ b/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py @@ -153,7 +153,7 @@ class PsetTemplatesGenerator: # 2) enumeration # 3) property set (also with IFCRELASSOCIATESLIBRARY) # 4) property definition (also with IFCRELASSOCIATESLIBRARY) - # but in ifc4x3 there is no data for those library refernces + # but in ifc4x3 there is no data for those library references # TODO: need to add it to .ifc for IFC4X3 too # if https://github.com/buildingSMART/IFC4.3.x-development/issues/587 is resolved diff --git a/src/ifcopenshell-python/ifcopenshell/util/representation.py b/src/ifcopenshell-python/ifcopenshell/util/representation.py index 4809f89b95..573c3692e0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/representation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/representation.py @@ -20,7 +20,7 @@ import numpy as np import numpy.typing as npt import ifcopenshell import ifcopenshell.util.placement -from typing import Optional, Union, TypedDict, Literal +from typing import Optional, Union, TypedDict, Literal, Iterator CONTEXT_TYPE = Literal["Model", "Plan", "NotDefined"] @@ -112,6 +112,19 @@ def is_representation_of_context( return representation.ContextOfItems.ContextType == context +def get_representations_iter(element: ifcopenshell.entity_instance) -> Iterator[ifcopenshell.entity_instance]: + """Get an iterator with element's IfcShapeRepresentations. + + :param element: An IfcProduct or IfcTypeProduct + """ + if element.is_a("IfcProduct") and (rep := element.Representation): + for r in rep.Representations: + yield r + elif element.is_a("IfcTypeProduct") and (maps := element.RepresentationMaps): + for r in maps: + yield r.MappedRepresentation + + def get_representation( element: ifcopenshell.entity_instance, context: Union[ifcopenshell.entity_instance, CONTEXT_TYPE], @@ -126,14 +139,9 @@ def get_representation( :param target_view: A TargetView string, or any if left blank. :return: The first IfcShapeRepresentation matching the criteria. """ - if element.is_a("IfcProduct") and element.Representation: - for r in element.Representation.Representations: - if is_representation_of_context(r, context, subcontext, target_view): - return r - elif element.is_a("IfcTypeProduct") and element.RepresentationMaps: - for r in element.RepresentationMaps: - if is_representation_of_context(r.MappedRepresentation, context, subcontext, target_view): - return r.MappedRepresentation + for r in get_representations_iter(element): # type: ignore + if is_representation_of_context(r, context, subcontext, target_view): + return r def resolve_representation(representation: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: diff --git a/src/ifcopenshell-python/ifcopenshell/util/resource.py b/src/ifcopenshell-python/ifcopenshell/util/resource.py index adce5bfa3f..8a444e6371 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/resource.py +++ b/src/ifcopenshell-python/ifcopenshell/util/resource.py @@ -166,9 +166,9 @@ def get_quantity(resource: ifcopenshell.entity_instance) -> float: return duration.total_seconds() / 3600 -def get_parent_cost(resource: ifcopenshell.entity_instance) -> Union[None, tuple[float, Union[str, None]]]: - if not resource.Nests: +def get_parent_cost(resource: ifcopenshell.entity_instance) -> Union[tuple[float, Union[str, None]], None]: + if not (nests := resource.Nests): return else: - cost = get_cost(resource.Nests[0].RelatingObject) + cost = get_cost(nests[0].RelatingObject) return cost diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index fe08240d07..35a042c74a 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -22,11 +22,12 @@ import time import ifcopenshell import ifcopenshell.util.attribute import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper -from typing import Union, Any +from typing import Union, Any, Literal # This is highly experimental and incomplete, however, it may work for simple datasets. cwd = os.path.dirname(os.path.realpath(__file__)) +IFC_SCHEMA = Literal["IFC2X3", "IFC4", "IFC4X3"] def get_fallback_schema(version: str) -> str: @@ -39,25 +40,108 @@ def get_fallback_schema(version: str) -> str: return version -def is_a(entity: ifcopenshell.entity_instance, ifc_class: str) -> bool: +def get_declaration(element: ifcopenshell.entity_instance): + """Get the schema declaration of an actively used entity instance + + IFC models are made out of instances (e.g. with a STEP ID) of entities + (e.g. IfcWall). Those entities are defined through a **Schema + Declaration**. + + **Schema Declaration** objects can be used to query information about the + IFC schema itself, such as data types, enumeration values, and inheritance. + + :param element: Any instance, typically from a loaded or created IFC model + + Example: + + .. code:: python + + wall = model.createIfcWall() + declaration = ifcopenshell.util.schema.get_declaration(wall) + print(declaration.name()) # IfcWall + print(declaration.is_abstract()) # False + print(declaration.supertype().name()) # IfcBuildingElement + """ + return element.wrapped_data.declaration().as_entity() + + +def is_a(declaration: ifcopenshell.ifcopenshell_wrapper.entity, ifc_class: str) -> bool: + """Checks if a schema declaration is a class + + :param declaration: The declaration from the schema. + :param ifc_class: A case insensitive IFC class name (e.g. IfcRoot) + :return: True is the declaration is of that class + + Example: + + .. code:: python + + wall = model.createIfcWall() + declaration = ifcopenshell.util.schema.get_declaration(wall) + ifcopenshell.util.schema.is_a(declaration, "IfcRoot") # True + """ ifc_class = ifc_class.upper() - if entity.name_uc() == ifc_class: + if declaration.name_uc() == ifc_class: return True - if entity.supertype(): - return is_a(entity.supertype(), ifc_class) + if declaration.supertype(): + return is_a(declaration.supertype(), ifc_class) return False -def get_subtypes(entity): - def get_classes(declaration): +def get_supertypes( + declaration: ifcopenshell.ifcopenshell_wrapper.entity, +) -> list[ifcopenshell.ifcopenshell_wrapper.entity]: + """Gets a list of supertype declarations + + :param declaration: The declaration from the schema, as an entity. + :return: A list of supertypes in order from parent to grandparent. + + Example: + + .. code:: python + + wall = model.createIfcWall() + results = ifcopenshell.util.schema.get_supertypes(wall.wrapped_data.declaration().as_entity()) + # [, , ..., ] + """ + results = [] + while True: + if not (declaration := declaration.supertype()): + break + results.append(declaration) + return results + + +def get_subtypes( + declaration: ifcopenshell.ifcopenshell_wrapper.entity, +) -> list[ifcopenshell.ifcopenshell_wrapper.entity]: + """Get a flat list of subtype declarations + + Abstract classes are skipped. + + Inconsistently, the declaration itself is also added to this list. This + should be fixed exclude the declaration itself. + + :param declaration: The declaration from the schema, as an entity. + :return: A list of subtypes in order from child to grandchild. + + .. code:: python + + schema = ifcopenshell.schema_by_name("IFC4") + declaration = schema.declaration_by_name("IfcFlowSegment") + print(ifcopenshell.util.schema.get_subtypes(declaration)) + [, , ..., ] + """ + + def get_classes(decl): results = [] - if not declaration.is_abstract(): - results.append(declaration) - for subtype in declaration.subtypes(): + if not decl.is_abstract(): + results.append(decl) + for subtype in decl.subtypes(): results.extend(get_classes(subtype)) return results - return get_classes(entity) + return get_classes(declaration) def reassign_class( diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 57e827c4ba..8497e99a07 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -507,6 +507,42 @@ def set_element_value( return elif key == "id": return + elif key == "predefined_type": + current_value = ifcopenshell.util.element.get_predefined_type(element) + if current_value == value: + return + + def set_predefined_type( + element: ifcopenshell.entity_instance, value: Union[str, None], *, is_type: bool + ) -> None: + predefined_type = element.PredefinedType + declaration = element.wrapped_data.declaration() + entity = declaration.as_entity() + enum_attr = next(attr for attr in entity.attributes() if attr.name() == "PredefinedType") + enum_items = ifcopenshell.util.attribute.get_enum_items(enum_attr) + + # USERDEFINED shouldn't occur here, if it does then it means + # then it was artificially added and PredefinedType is actually unset. + if value in (None, "NOTDEFINED", "USERDEFINED"): + element.PredefinedType = "NOTDEFINED" + setattr(element, "ElementType" if is_type else "ObjectType", None) + elif value in enum_items: + if predefined_type == value: + return + element.PredefinedType = value + return + + # Value not in PredefinedType enum items. + if predefined_type != "USERDEFINED": + element.PredefinedType = "USERDEFINED" + setattr(element, "ElementType" if is_type else "ObjectType", value) + return + + if element_type := ifcopenshell.util.element.get_type(element): + set_predefined_type(element_type, value, is_type=True) + return + set_predefined_type(element, value, is_type=False) + return elif key == "classification": element = ifcopenshell.util.classification.get_references(element) elif key in ("x", "y", "z", "easting", "northing", "elevation") and hasattr(element, "ObjectPlacement"): diff --git a/src/ifcopenshell-python/ifcopenshell/util/sequence.py b/src/ifcopenshell-python/ifcopenshell/util/sequence.py index f476df5792..720bebfb22 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/util/sequence.py @@ -36,7 +36,18 @@ RECURRENCE_TYPE = Literal[ ] -def derive_date(task, attribute_name, date=None, is_earliest=False, is_latest=False): +def derive_date( + task: ifcopenshell.entity_instance, + attribute_name: str, + date=None, + is_earliest: bool = False, + is_latest: bool = False, +): + """ + + :param task: IfcTask. + + """ if task.TaskTime: current_date = ( ifcopenshell.util.date.ifc2datetime(getattr(task.TaskTime, attribute_name)) @@ -268,7 +279,7 @@ def get_task_work_schedule(task: ifcopenshell.entity_instance) -> Union[ifcopens def get_nested_tasks(task: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: - return [object for rel in task.IsNestedBy or [] for object in rel.RelatedObjects] + return [object for rel in task.IsNestedBy or [] for object in rel.RelatedObjects if object.is_a("IfcTask")] def get_parent_task(task: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: @@ -448,12 +459,9 @@ def get_related_products( """Gets the related products being output by a task :param relating_product: One of the products already output by the task. - :type relating_product: ifcopenshell.entity_instance, optional :param related_object: The IfcTask that you want to get all the related products for. - :type related_object: ifcopenshell.entity_instance, optional :return: A set of IfcProducts output by the IfcTask. - :rtype: set[ifcopenshell.entity_instance] Example: @@ -478,17 +486,18 @@ def get_related_products( products = ifcopenshell.util.sequence.get_related_products(related_object=task) """ + assert relating_product or related_object, "Either relating_product or related_object must be provided." + products = set() - related_object = None - if related_object: - related_object = related_object - elif relating_product: + if not related_object and relating_product: for reference in relating_product.ReferencedBy: if reference.is_a("IfcRelAssignsToProduct"): related_object = reference.RelatedObjects[0] + if related_object: assignments = related_object.HasAssignments for assignment in assignments: if assignment.is_a("IfcRelAssignsToProduct"): products.add(assignment.RelatingProduct.id()) + return products diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape.py b/src/ifcopenshell-python/ifcopenshell/util/shape.py index eae0394dcf..4a92d6c79f 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape.py @@ -229,7 +229,7 @@ def get_shape_bbox_centroid(shape: ShapeType, geometry: ShapeType) -> npt.NDArra return (get_shape_matrix(shape) @ np.array([*centroid, 1.0]))[0:3] -def get_vertices(geometry: ShapeType) -> npt.NDArray[np.float64]: +def get_vertices(geometry: ShapeType, is_2d: bool = False) -> npt.NDArray[np.float64]: """Get all the vertices as a numpy array Vertices are in local coordinates. @@ -238,9 +238,12 @@ def get_vertices(geometry: ShapeType) -> npt.NDArray[np.float64]: :param geometry: Geometry output calculated by IfcOpenShell :type geometry: geometry + :param is_2d: Set to True to to get XY coordinates only. :return: A numpy array listing all the vertices. Each vertex is a numpy array with XYZ coordinates. :rtype: np.array[np.array[float]] """ + if is_2d: + return np.frombuffer(geometry.verts_buffer, "d").reshape(-1, 3)[:, :2] return np.frombuffer(geometry.verts_buffer, "d").reshape(-1, 3) @@ -277,11 +280,40 @@ def get_faces(geometry: ShapeType) -> npt.NDArray[np.int32]: return np.frombuffer(geometry.faces_buffer, dtype="i").reshape(-1, 3) -def get_representation_item_ids(geometry: ShapeType) -> npt.NDArray[np.int32]: +def get_material_colors(geometry: ShapeType) -> npt.NDArray[np.float64]: + """Get material colors as a numpy array. + + :return: A numpy array listing RGBA color for each shape's material. + """ + # colors_buffer comes from geometry.materials and doesn't account + # for colors that can be set by some other way (e.g. IfcIndexedColourMap). + return np.frombuffer(geometry.colors_buffer, dtype="d").reshape(-1, 4) + + +def get_normals(geometry: ShapeType) -> npt.NDArray[np.float64]: + """Get vertex normals as a numpy array. + + See geometry settings documentation for settings that affect normals. + + :return: A numpy array listing normal for each shape vertex. + """ + return np.frombuffer(geometry.normals_buffer, dtype="d").reshape(-1, 3) + + +def get_faces_representation_item_ids(geometry: ShapeType) -> npt.NDArray[np.int32]: """Get representation item ids for the geometry faces.""" return np.frombuffer(geometry.item_ids_buffer, dtype="i") +def get_edges_representation_item_ids(geometry: ShapeType) -> npt.NDArray[np.int32]: + """Get representation item ids for the geometry edges. + + Can be useful for geometry without faces and in general is more universal + since it's possible that geometry will have elements with and without faces. + """ + return np.frombuffer(geometry.edges_item_ids_buffer, dtype="i") + + def get_shape_vertices(shape: ShapeType, geometry: ShapeType) -> npt.NDArray[np.float64]: """Get the shape's vertices as a numpy array diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 57868109ba..b3a7c29e0e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -228,10 +228,8 @@ class ShapeBuilder: :return: IfcCircle :rtype: ifcopenshell.entity_instance """ - ifc_center = self.file.createIfcAxis2Placement2D(self.file.createIfcCartesianPoint(center)) + ifc_center = self.create_axis2_placement_2d(center) ifc_curve = self.file.createIfcCircle(ifc_center, radius) - - # self.file_file.createIfcAxis2Placement2D(tool.Ifc.get().createIfcCartesianPoint(center[0:2])) return ifc_curve def plane( @@ -323,10 +321,7 @@ class ShapeBuilder: Notion: trimmed ellipse also contains polyline between trim points, meaning IfcTrimmedCurve could be used for further extrusion. """ - direction = self.file.createIfcDirection(ref_x_direction) - ifc_position = self.file.createIfcAxis2Placement2D( - self.file.createIfcCartesianPoint(position), RefDirection=direction - ) + ifc_position = self.create_axis2_placement_2d(position, ref_x_direction) ifc_ellipse = self.file.createIfcEllipse( Position=ifc_position, SemiAxis1=x_axis_radius, SemiAxis2=y_axis_radius ) @@ -372,10 +367,9 @@ class ShapeBuilder: "ProfileType": profile_type, "OuterCurve": outer_curve, } + if self.file.schema == "IFC2X3": - kwargs["Position"] = self.file.create_entity( - "IfcAxis2Placement2D", self.file.create_entity("IfcCartesianPoint", [0.0, 0.0]) - ) + kwargs["Position"] = self.create_axis2_placement_2d() if inner_curves: if not isinstance(inner_curves, collections.abc.Iterable): @@ -584,6 +578,17 @@ class ShapeBuilder: position=matrix[:, 3][:3].tolist(), z_axis=matrix[:, 2][:3].tolist(), x_axis=matrix[:, 0][:3].tolist() ) + def create_axis2_placement_2d( + self, position: VectorTuple = (0.0, 0.0), x_direction: Optional[VectorTuple] = None + ) -> ifcopenshell.entity_instance: + """Create IfcAxis2Placement2D.""" + ref_direction = self.file.create_entity("IfcDirection", x_direction) if x_direction else None + return self.file.create_entity( + "IfcAxis2Placement2D", + Location=self.file.create_entity("IfcCartesianPoint", position), + RefDirection=ref_direction, + ) + def mirror( self, curve_or_item: Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], @@ -1081,24 +1086,42 @@ class ShapeBuilder: ) return points, segments, transition_arc + def mesh(self, points: list[Vector], faces: list[list[int]]) -> ifcopenshell.entity_instance: + if self.file.schema == "IFC2X3": + return self.faceted_brep(points, faces) + return self.polygonal_face_set(points, faces) + + def faceted_brep(self, points: list[Vector], faces: list[list[int]]) -> ifcopenshell.entity_instance: + """Generate an IfcFacetedBrep with a closed shell + + Note that :func:`polygonal_face_set` is recommended in IFC4. + + :param points: list of 3d coordinates + :param faces: list of faces consisted of point indices (points indices starting from 0) + :return: IfcFacetedBrep + """ + verts = [self.file.createIfcCartesianPoint(p) for p in points] + faces = [ + self.file.createIfcFace( + [self.file.createIfcFaceOuterBound(self.file.createIfcPolyLoop([verts[v] for v in f]), True)] + ) + for f in faces + ] + return self.file.createIfcFacetedBrep(self.file.createIfcClosedShell(faces)) + def polygonal_face_set(self, points: list[Vector], faces: list[list[int]]) -> ifcopenshell.entity_instance: """ - Generate an IfcPolygonalFaceSet. + Generate an IfcPolygonalFaceSet - :param points: rectangle size, could be either 2d or 3d, defaults to `(1,1)` + Note that this is not available in IFC2X3. + + :param points: list of 3d coordinates :param faces: list of faces consisted of point indices (points indices starting from 0) :return: IfcPolygonalFaceSet """ - ifc_points = self.file.createIfcCartesianPointList3D(points) - ifc_faces = [] - for face in faces: - face = [i + 1 for i in face] - ifc_faces.append(self.file.createIfcIndexedPolygonalFace(face)) - - face_set = self.file.createIfcPolygonalFaceSet(Coordinates=ifc_points, Faces=ifc_faces) - - return face_set + ifc_faces = [self.file.createIfcIndexedPolygonalFace([i + 1 for i in face]) for face in faces] + return self.file.createIfcPolygonalFaceSet(Coordinates=ifc_points, Faces=ifc_faces) def extrude_face_set( self, diff --git a/src/ifcopenshell-python/ifcopenshell/util/type.py b/src/ifcopenshell-python/ifcopenshell/util/type.py index 3cd2750d2b..fed14d69a0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/type.py +++ b/src/ifcopenshell-python/ifcopenshell/util/type.py @@ -19,12 +19,11 @@ import os import json import ifcopenshell.util.schema -from typing import List cwd = os.path.dirname(os.path.realpath(__file__)) -entity_to_type_map = {} -type_to_entity_map = {} +entity_to_type_map: dict[ifcopenshell.util.schema.IFC_SCHEMA, dict[str, list[str]]] = {} +type_to_entity_map: dict[ifcopenshell.util.schema.IFC_SCHEMA, dict[str, list[str]]] = {} mapped_schemas = { "IFC2X3": "entity_to_type_map_2x3.json", @@ -52,11 +51,19 @@ for schema in mapped_schemas: type_to_entity_map[schema][element_type] = [e for e in elements if guessed_element in e] -def get_applicable_types(ifc_class: str, schema="IFC4") -> List[str]: +def get_applicable_types(ifc_class: str, schema="IFC4") -> list[str]: + """Get applicable types IFC classes for the occurrence IFC class. + + E.g. "IfcWindow" -> ["IfcWindowType"]. + """ schema = ifcopenshell.util.schema.get_fallback_schema(schema.upper()) return entity_to_type_map[schema].get(ifc_class, []) -def get_applicable_entities(ifc_type_class: str, schema="IFC4") -> List[str]: +def get_applicable_entities(ifc_type_class: str, schema="IFC4") -> list[str]: + """Get applicable occurrence IFC classes for the type IFC class. + + E.g. "IfcWindowType" -> ["IfcWindow"]. + """ schema = ifcopenshell.util.schema.get_fallback_schema(schema.upper()) return type_to_entity_map[schema].get(ifc_type_class, []) diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index 6c78d7c063..7dd12326c7 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -43,6 +43,7 @@ from logging import Logger, Handler import ifcopenshell import ifcopenshell.ifcopenshell_wrapper +import ifcopenshell.ifcopenshell_wrapper as W import ifcopenshell.express.rule_executor named_type = ifcopenshell.ifcopenshell_wrapper.named_type @@ -95,11 +96,13 @@ simple_type_python_mapping = { } -def annotate_inst_attr_pos(inst: ifcopenshell.entity_instance, pos: int) -> str: +def annotate_inst_attr_pos( + inst: Union[ifcopenshell.entity_instance, W.HeaderEntity], pos: int, entity_str: str = "" +) -> str: def get_pos() -> Iterator[int]: depth = 0 idx = -1 - for c in str(inst): + for c in entity_str or str(inst): if c == "(": depth += 1 if depth == 1: @@ -389,6 +392,8 @@ def validate(f: Union[ifcopenshell.file, str], logger: Logger, express_rules=Fal log_internal_cpp_errors(f, filename, logger) + validate_ifc_header(f, logger) + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(f.schema_identifier) used_guids: dict[str, ifcopenshell.entity_instance] = dict() @@ -402,7 +407,7 @@ def validate(f: Union[ifcopenshell.file, str], logger: Logger, express_rules=Fal rule = "Rule IfcRoot.UR1:\n The attribute GlobalId should be unique" previous_element = used_guids[guid] logger.error( - "On instance:\n %s\n %s\n%s\nViolated by:\n %s\n %s", + "On instance:\n %s\n %s\n%s\nViolated by:\n %s\n %s", inst, annotate_inst_attr_pos(inst, 0), rule, @@ -521,12 +526,72 @@ def validate(f: Union[ifcopenshell.file, str], logger: Logger, express_rules=Fal ifcopenshell.express.rule_executor.run(f, logger) +def validate_ifc_header(f: ifcopenshell.file, logger: Logger) -> None: + header: W.IfcSpfHeader = f.wrapped_data.header + AGGREGATE_TYPE = "LIST [ 1 : ? ] OF STRING (256)" + STRING_TYPE = "STRING (256)" + + def log_error(header_entity: W.HeaderEntity, name: str, index: int, expected_type: str, provided_type: str) -> None: + logger.error( + ( + "For instance:\n %s\n %s\n" + "Attribute '%s' has invalid type:\n" + " Expected: %s\n Current value type: %s\n" + ), + (s := header_entity.toString()), + annotate_inst_attr_pos(header_entity, index, s), + name, + expected_type, + provided_type, + ) + + def validate_attribute(header_entity: W.HeaderEntity, name: str, index: int, *, aggregate: bool = False) -> None: + value = getattr(header_entity, name) + if aggregate: + if not isinstance(value, tuple): + log_error(header_entity, name, index, AGGREGATE_TYPE, type(value).__name__) + return + if not value: + log_error(header_entity, name, index, AGGREGATE_TYPE, "EMPTY LIST") + return + if not all(isinstance(last_value := v, str) for v in value): + log_error( + header_entity, + name, + index, + AGGREGATE_TYPE, + f"LIST with {type(last_value).__name__} (value: {last_value})", + ) + return + + if not isinstance(value, str): + log_error(header_entity, name, index, STRING_TYPE, type(value).__name__) + + # Ignore header.file_schema as file won't load to IfcOpenShell with invalid file_schema. + file_description: W.FileDescription = header.file_description + validate_attribute(file_description, "description", 0, aggregate=True) + validate_attribute(file_description, "implementation_level", 1) + file_name: W.FileName = header.file_name + validate_attribute(file_name, "name", 0) + validate_attribute(file_name, "time_stamp", 1) + validate_attribute(file_name, "author", 2, aggregate=True) + validate_attribute(file_name, "organization", 3, aggregate=True) + validate_attribute(file_name, "preprocessor_version", 4) + validate_attribute(file_name, "originating_system", 5) + validate_attribute(file_name, "authorization", 6) + + class LogDetectionHandler(Handler): message_logged = False + def __init__(self): + super().__init__() + self.default_handler = logging.StreamHandler() + def emit(self, record): if not self.message_logged: self.message_logged = True + self.default_handler.emit(record) if __name__ == "__main__": @@ -535,6 +600,7 @@ if __name__ == "__main__": filenames = [x for x in sys.argv[1:] if not x.startswith("--")] flags = set(x for x in sys.argv[1:] if x.startswith("--")) + some_file_is_invalid = False for fn in filenames: handler = None @@ -571,5 +637,10 @@ if __name__ == "__main__": else: # json_logger. invalid_ifc = bool(logger.statements) - if not invalid_ifc: + if invalid_ifc: + some_file_is_invalid = True + else: print("No validation issues found.") + + if some_file_is_invalid: + exit(1) diff --git a/src/ifcopenshell-python/test/api/geometry/test_unassign_representation.py b/src/ifcopenshell-python/test/api/geometry/test_unassign_representation.py index 579cee589b..1e45054369 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_unassign_representation.py +++ b/src/ifcopenshell-python/test/api/geometry/test_unassign_representation.py @@ -25,26 +25,41 @@ class TestUnassignRepresentation(test.bootstrap.IFC4): def test_unassigning_a_product_representation(self): representation = self.file.createIfcShapeRepresentation() representation2 = self.file.createIfcShapeRepresentation() + item = self.file.create_entity("IfcExtrudedAreaSolid") + representation2.Items = (item,) wall = self.file.createIfcWall( Representation=self.file.createIfcProductDefinitionShape(Representations=[representation, representation2]) ) + + shape_aspect = self.file.create_entity("IfcShapeAspect") + shape_aspect.ShapeRepresentations = (self.file.createIfcShapeRepresentation(Items=(item,)),) + shape_aspect.PartOfProductDefinitionShape = wall.Representation + ifcopenshell.api.geometry.unassign_representation(self.file, product=wall, representation=representation) assert representation not in wall.Representation.Representations ifcopenshell.api.geometry.unassign_representation(self.file, product=wall, representation=representation2) assert not wall.Representation assert len(self.file.by_type("IfcShapeRepresentation")) == 2 assert len(self.file.by_type("IfcProductDefinitionShape")) == 0 + assert len(self.file.by_type("IfcShapeAspect")) == 0 def test_unassigning_a_type_product_representation(self): - representation = self.file.createIfcShapeRepresentation() + item = self.file.create_entity("IfcExtrudedAreaSolid") + representation = self.file.createIfcShapeRepresentation(Items=(item,)) origin = self.file.createIfcAxis2Placement3D() repmap = self.file.createIfcRepresentationMap(MappedRepresentation=representation, MappingOrigin=origin) walltype = self.file.createIfcWallType(RepresentationMaps=[repmap]) + + shape_aspect = self.file.create_entity("IfcShapeAspect") + shape_aspect.ShapeRepresentations = (self.file.createIfcShapeRepresentation(Items=(item,)),) + shape_aspect.PartOfProductDefinitionShape = repmap + ifcopenshell.api.geometry.unassign_representation(self.file, product=walltype, representation=representation) assert not walltype.RepresentationMaps assert len(self.file.by_type("IfcAxis2Placement3D")) == 0 assert len(self.file.by_type("IfcRepresentationMap")) == 0 assert len(self.file.by_type("IfcShapeRepresentation")) == 1 + assert len(self.file.by_type("IfcShapeAspect")) == 0 def test_unassigning_a_type_product_representation_used_by_instances(self): representation = self.file.createIfcShapeRepresentation() @@ -62,3 +77,7 @@ class TestUnassignRepresentation(test.bootstrap.IFC4): assert len(self.file.by_type("IfcShapeRepresentation")) == 1 assert not wall.Representation assert len(self.file.by_type("IfcProductDefinitionShape")) == 0 + + +class TestUnassignRepresentationIFC2X3(test.bootstrap.IFC2X3, TestUnassignRepresentation): + pass diff --git a/src/ifcopenshell-python/test/api/profile/test_copy_profile.py b/src/ifcopenshell-python/test/api/profile/test_copy_profile.py new file mode 100644 index 0000000000..8cc19b1489 --- /dev/null +++ b/src/ifcopenshell-python/test/api/profile/test_copy_profile.py @@ -0,0 +1,45 @@ +# 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.profile +import ifcopenshell.api.pset +import ifcopenshell.util.element + + +class TestCopyProfileIFC2X3(test.bootstrap.IFC2X3): + def test_copy_profile(self): + profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, "IfcRectangleProfileDef") + new_profile = ifcopenshell.api.profile.copy_profile(self.file, profile=profile) + assert new_profile.is_a("IfcRectangleProfileDef") + assert profile != new_profile + assert len(self.file.by_type("IfcRectangleProfileDef")) == 2 + + def test_copy_profile_with_pset(self): + profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, "IfcRectangleProfileDef") + pset = ifcopenshell.api.pset.add_pset(self.file, product=profile, name="ProfilePset") + new_profile = ifcopenshell.api.profile.copy_profile(self.file, profile=profile) + + assert len(self.file.by_type("IfcRectangleProfileDef")) == 2 + assert len(psets := self.file.by_type("IfcProfileProperties")) == 2 + assert ifcopenshell.util.element.get_elements_by_pset(pset) == {profile} + assert ifcopenshell.util.element.get_elements_by_pset(psets[1]) == {new_profile} + + +class TestCopyProfileIFC4(test.bootstrap.IFC4, TestCopyProfileIFC2X3): + pass diff --git a/src/ifcopenshell-python/test/api/profile/test_remove_profile.py b/src/ifcopenshell-python/test/api/profile/test_remove_profile.py new file mode 100644 index 0000000000..86b3e136e7 --- /dev/null +++ b/src/ifcopenshell-python/test/api/profile/test_remove_profile.py @@ -0,0 +1,39 @@ +# 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.profile +import ifcopenshell.api.pset + + +class TestRemoveProfileIFC2X3(test.bootstrap.IFC2X3): + def test_removing_profile(self): + profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, "IfcRectangleProfileDef") + ifcopenshell.api.profile.remove_profile(self.file, profile=profile) + assert len(self.file.by_type("IfcRectangleProfileDef")) == 0 + + def test_removing_profile_with_pset(self): + profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, "IfcRectangleProfileDef") + pset = ifcopenshell.api.pset.add_pset(self.file, product=profile, name="ProfilePset") + ifcopenshell.api.profile.remove_profile(self.file, profile=profile) + assert len(self.file.by_type("IfcRectangleProfileDef")) == 0 + assert len(self.file.by_type("IfcProfileProperties")) == 0 + + +class TestRemoveProfileIFC4(test.bootstrap.IFC4, TestRemoveProfileIFC2X3): + pass diff --git a/src/ifcopenshell-python/test/api/pset/test_add_pset.py b/src/ifcopenshell-python/test/api/pset/test_add_pset.py index 1bbd6ef0be..fadb1a369c 100644 --- a/src/ifcopenshell-python/test/api/pset/test_add_pset.py +++ b/src/ifcopenshell-python/test/api/pset/test_add_pset.py @@ -38,17 +38,23 @@ class TestAddPset(test.bootstrap.IFC4): assert "Pset_WallCommon" in ifcopenshell.util.element.get_psets(element) def test_adding_a_pset_to_a_material(self): + is_ifc2x3 = self.file.schema == "IFC2X3" material = ifcopenshell.api.material.add_material(self.file) pset = ifcopenshell.api.pset.add_pset(self.file, product=material, name="Pset_MaterialCommon") assert pset.is_a("IfcMaterialProperties") + if is_ifc2x3: + assert pset.is_a() == "IfcExtendedMaterialProperties" assert pset.Name == "Pset_MaterialCommon" assert pset.Material == material def test_adding_a_pset_to_a_profile(self): + is_ifc2x3 = self.file.schema == "IFC2X3" profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, ifc_class="IfcCircleProfileDef") pset = ifcopenshell.api.pset.add_pset(self.file, product=profile, name="Pset_ProfileMechanical") assert pset.is_a("IfcProfileProperties") - if self.file.schema != "IFC2X3": + if is_ifc2x3: + assert pset.is_a() == "IfcGeneralProfileProperties" + else: assert pset.Name == "Pset_ProfileMechanical" assert pset.ProfileDefinition == profile @@ -65,3 +71,24 @@ class TestAddPsetIFC2X3(test.bootstrap.IFC2X3, TestAddPset): pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Custom_Pset") assert pset.is_a("IfcPropertySet") assert "Custom_Pset" in ifcopenshell.util.element.get_psets(element) + + def test_adding_a_pset_subclass_to_a_profile(self): + profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, ifc_class="IfcCircleProfileDef") + pset = ifcopenshell.api.pset.add_pset( + self.file, + product=profile, + name="Pset_ProfileMechanical", + ifc2x3_subclass="IfcStructuralSteelProfileProperties", + ) + assert pset.is_a() == "IfcStructuralSteelProfileProperties" + + def test_adding_a_material_subclass_to_a_profile(self): + is_ifc2x3 = self.file.schema == "IFC2X3" + material = ifcopenshell.api.material.add_material(self.file) + pset = ifcopenshell.api.pset.add_pset( + self.file, + product=material, + name="Pset_MaterialCommon", + ifc2x3_subclass="IfcFuelProperties", + ) + assert pset.is_a() == "IfcFuelProperties" diff --git a/src/ifcopenshell-python/test/api/pset/test_assign_pset.py b/src/ifcopenshell-python/test/api/pset/test_assign_pset.py new file mode 100644 index 0000000000..950d2743cb --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset/test_assign_pset.py @@ -0,0 +1,58 @@ +# 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 test.bootstrap +import ifcopenshell.api.pset +import ifcopenshell.util.element + + +class TestAssignPset(test.bootstrap.IFC4): + def test_assign_pset_to_occurrence(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + rel = ifcopenshell.api.pset.assign_pset(self.file, elements, pset) + assert rel + + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 1 + assert rel.RelatingPropertyDefinition == pset + assert set(rel.RelatedObjects) == set(elements) + + def test_assign_pset_to_occurrence_preexisting_rel(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + rel = ifcopenshell.api.pset.assign_pset(self.file, elements[:1], pset) + assert rel + + rel_updated = ifcopenshell.api.pset.assign_pset(self.file, elements[1:], pset) + assert rel_updated == rel + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 1 + assert rel.RelatingPropertyDefinition == pset + assert set(rel.RelatedObjects) == set(elements) + + def test_assign_pset_to_type(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + ret = ifcopenshell.api.pset.assign_pset(self.file, elements, pset) + assert ret is None + + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + assert set(pset.DefinesType) == set(elements) + + +class TestAssignPsetIFC2X3(test.bootstrap.IFC2X3, TestAssignPset): + pass diff --git a/src/ifcopenshell-python/test/api/pset/test_edit_pset.py b/src/ifcopenshell-python/test/api/pset/test_edit_pset.py index c7d5bae2e9..257297f6b9 100644 --- a/src/ifcopenshell-python/test/api/pset/test_edit_pset.py +++ b/src/ifcopenshell-python/test/api/pset/test_edit_pset.py @@ -21,55 +21,21 @@ import test.bootstrap import ifcopenshell.api.pset import ifcopenshell.api.root import ifcopenshell.guid +import ifcopenshell.util.element +from typing import Union -class TestEditPset(test.bootstrap.IFC4): - def test_editing_a_blank_buildingsmart_templated_pset(self): - element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") - pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") - ifcopenshell.api.pset.edit_pset( - self.file, - pset=pset, - properties={"Reference": "reference", "Status": ["NEW"], "Combustible": True, "ThermalTransmittance": 42}, - ) - pset = element.IsDefinedBy[0].RelatingPropertyDefinition +def get_properties(material: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: + ifc_file = material.file + if ifc_file.schema == "IFC2X3": + for props in ifc_file.by_type("IfcExtendedMaterialProperties"): + if props.Material == material: + return props + return None + return next(iter(material.HasProperties), None) - assert pset.HasProperties[0].Name == "Reference" - assert pset.HasProperties[0].NominalValue.is_a("IfcIdentifier") - assert pset.HasProperties[0].NominalValue.wrappedValue == "reference" - - assert pset.HasProperties[1].Name == "Status" - assert pset.HasProperties[1].EnumerationValues[0].is_a("IfcLabel") - assert pset.HasProperties[1].EnumerationValues[0].wrappedValue == "NEW" - - assert pset.HasProperties[2].Name == "Combustible" - assert pset.HasProperties[2].NominalValue.is_a("IfcBoolean") - assert pset.HasProperties[2].NominalValue.wrappedValue == True - - assert pset.HasProperties[3].Name == "ThermalTransmittance" - assert pset.HasProperties[3].NominalValue.is_a("IfcThermalTransmittanceMeasure") - assert pset.HasProperties[3].NominalValue.wrappedValue == 42 - - def test_editing_an_existing_buildingsmart_templated_pset(self): - element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") - pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") - ifcopenshell.api.pset.edit_pset( - self.file, - pset=pset, - properties={"Reference": "foo", "Status": ["NEW"], "Combustible": True, "ThermalTransmittance": 42}, - ) - ifcopenshell.api.pset.edit_pset( - self.file, pset=pset, properties={"Reference": "bar", "Status": []}, should_purge=False - ) - pset = element.IsDefinedBy[0].RelatingPropertyDefinition - - assert pset.HasProperties[0].Name == "Reference" - assert pset.HasProperties[0].NominalValue.is_a("IfcIdentifier") - assert pset.HasProperties[0].NominalValue.wrappedValue == "bar" - - assert pset.HasProperties[1].Name == "Status" - assert pset.HasProperties[1].EnumerationValues is None +class TestEditPsetIFC2X3(test.bootstrap.IFC2X3): def test_editing_a_templated_pset_with_automatic_casting_of_primitive_data_types(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") @@ -107,16 +73,6 @@ class TestEditPset(test.bootstrap.IFC4): pset = element.IsDefinedBy[0].RelatingPropertyDefinition assert len(pset.HasProperties) == 0 - def test_removing_a_none_enumeration_property_if_specified(self): - element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") - pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") - ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": ["NEW"]}) - assert pset.HasProperties[0].Name == "Status" - assert pset.HasProperties[0].EnumerationValues[0].wrappedValue == "NEW" - ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": []}, should_purge=True) - pset = element.IsDefinedBy[0].RelatingPropertyDefinition - assert len(pset.HasProperties) == 0 - def test_editing_a_pset_name(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="foo") @@ -182,21 +138,6 @@ class TestEditPset(test.bootstrap.IFC4): assert pset.HasProperties[0].NominalValue.is_a("IfcContextDependentMeasure") assert pset.HasProperties[0].NominalValue.wrappedValue == 34 - def test_editing_list_valued_properties(self): - cable = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDistributionPort", predefined_type="CABLE") - pset = ifcopenshell.api.pset.add_pset(self.file, product=cable, name="Pset_DistributionPortTypeCable") - ifcopenshell.api.pset.edit_pset( - self.file, - pset=pset, - properties={ - "Protocols": ["One", "Two", "Three"], - }, - ) - assert pset.HasProperties[0].is_a("IfcPropertyListValue") - assert len(pset.HasProperties[0].ListValues) == 3 - assert set(map(ifcopenshell.entity_instance.is_a, pset.HasProperties[0].ListValues)) == {"IfcIdentifier"} - assert list(map(operator.itemgetter(0), pset.HasProperties[0].ListValues)) == ["One", "Two", "Three"] - def test_editing_properties_with_an_explicit_type(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar") @@ -251,10 +192,82 @@ class TestEditPset(test.bootstrap.IFC4): pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"foo": "bar"}) - assert element.HasProperties[0] == pset - assert pset.Properties[0].Name == "foo" - assert pset.Properties[0].NominalValue.is_a("IfcLabel") - assert pset.Properties[0].NominalValue.wrappedValue == "bar" + props = get_properties(element) + assert props + assert props == pset + props_ = getattr(props, "ExtendedProperties" if self.file.schema == "IFC2X3" else "Properties") + assert props_[0].Name == "foo" + assert props_[0].NominalValue.is_a("IfcLabel") + assert props_[0].NominalValue.wrappedValue == "bar" + + def test_editing_a_shared_property(self): + element1 = self.file.createIfcMaterial() + element2 = self.file.createIfcMaterial() + pset1 = ifcopenshell.api.pset.add_pset(self.file, product=element1, name="Foo_Bar") + pset2 = ifcopenshell.api.pset.add_pset(self.file, product=element2, name="Foo_Bar") + ifcopenshell.api.pset.edit_pset(self.file, pset=pset1, properties={"foo": "bar"}) + ifcopenshell.api.pset.edit_pset(self.file, pset=pset2, properties={"foo2": "bar2"}) + + props1 = get_properties(element1) + props2 = get_properties(element2) + assert props1 and props2 + props_attr = "ExtendedProperties" if self.file.schema == "IFC2X3" else "Properties" + shared_props = list(getattr(props1, props_attr)) + list(getattr(props2, props_attr)) + setattr(props1, props_attr, shared_props) + + assert ifcopenshell.util.element.get_pset(element1, "Foo_Bar", "foo2") == "bar2" + assert ifcopenshell.util.element.get_pset(element2, "Foo_Bar", "foo2") == "bar2" + ifcopenshell.api.pset.edit_pset(self.file, pset=pset1, properties={"foo2": "bar3"}) + assert ifcopenshell.util.element.get_pset(element1, "Foo_Bar", "foo2") == "bar3" + assert ifcopenshell.util.element.get_pset(element2, "Foo_Bar", "foo2") == "bar2" + + +class TestEditPsetIFC4(test.bootstrap.IFC4, TestEditPsetIFC2X3): + def test_editing_a_blank_buildingsmart_templated_pset(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") + ifcopenshell.api.pset.edit_pset( + self.file, + pset=pset, + properties={"Reference": "reference", "Status": ["NEW"], "Combustible": True, "ThermalTransmittance": 42}, + ) + pset = element.IsDefinedBy[0].RelatingPropertyDefinition + + assert pset.HasProperties[0].Name == "Reference" + assert pset.HasProperties[0].NominalValue.is_a("IfcIdentifier") + assert pset.HasProperties[0].NominalValue.wrappedValue == "reference" + + assert pset.HasProperties[1].Name == "Status" + assert pset.HasProperties[1].EnumerationValues[0].is_a("IfcLabel") + assert pset.HasProperties[1].EnumerationValues[0].wrappedValue == "NEW" + + assert pset.HasProperties[2].Name == "Combustible" + assert pset.HasProperties[2].NominalValue.is_a("IfcBoolean") + assert pset.HasProperties[2].NominalValue.wrappedValue == True + + assert pset.HasProperties[3].Name == "ThermalTransmittance" + assert pset.HasProperties[3].NominalValue.is_a("IfcThermalTransmittanceMeasure") + assert pset.HasProperties[3].NominalValue.wrappedValue == 42 + + def test_editing_an_existing_buildingsmart_templated_pset(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") + ifcopenshell.api.pset.edit_pset( + self.file, + pset=pset, + properties={"Reference": "foo", "Status": ["NEW"], "Combustible": True, "ThermalTransmittance": 42}, + ) + ifcopenshell.api.pset.edit_pset( + self.file, pset=pset, properties={"Reference": "bar", "Status": []}, should_purge=False + ) + pset = element.IsDefinedBy[0].RelatingPropertyDefinition + + assert pset.HasProperties[0].Name == "Reference" + assert pset.HasProperties[0].NominalValue.is_a("IfcIdentifier") + assert pset.HasProperties[0].NominalValue.wrappedValue == "bar" + + assert pset.HasProperties[1].Name == "Status" + assert pset.HasProperties[1].EnumerationValues is None def test_editing_a_custom_templated_pset(self): template = self.file.create_entity( @@ -292,18 +305,27 @@ class TestEditPset(test.bootstrap.IFC4): assert pset.HasProperties[0].NominalValue.is_a("IfcContextDependentMeasure") assert pset.HasProperties[0].NominalValue.wrappedValue == 12 - def test_editing_a_shared_property(self): - element1 = self.file.createIfcMaterial() - element2 = self.file.createIfcMaterial() - pset1 = ifcopenshell.api.pset.add_pset(self.file, product=element1, name="Foo_Bar") - pset2 = ifcopenshell.api.pset.add_pset(self.file, product=element2, name="Foo_Bar") - ifcopenshell.api.pset.edit_pset(self.file, pset=pset1, properties={"foo": "bar"}) - ifcopenshell.api.pset.edit_pset(self.file, pset=pset2, properties={"foo2": "bar2"}) - element1.HasProperties[0].Properties = list(element1.HasProperties[0].Properties) + list( - element2.HasProperties[0].Properties + def test_removing_a_none_enumeration_property_if_specified(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") + ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": ["NEW"]}) + assert pset.HasProperties[0].Name == "Status" + assert pset.HasProperties[0].EnumerationValues[0].wrappedValue == "NEW" + ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": []}, should_purge=True) + pset = element.IsDefinedBy[0].RelatingPropertyDefinition + assert len(pset.HasProperties) == 0 + + def test_editing_list_valued_properties(self): + cable = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDistributionPort", predefined_type="CABLE") + pset = ifcopenshell.api.pset.add_pset(self.file, product=cable, name="Pset_DistributionPortTypeCable") + ifcopenshell.api.pset.edit_pset( + self.file, + pset=pset, + properties={ + "Protocols": ["One", "Two", "Three"], + }, ) - assert ifcopenshell.util.element.get_pset(element1, "Foo_Bar", "foo2") == "bar2" - assert ifcopenshell.util.element.get_pset(element2, "Foo_Bar", "foo2") == "bar2" - ifcopenshell.api.pset.edit_pset(self.file, pset=pset1, properties={"foo2": "bar3"}) - assert ifcopenshell.util.element.get_pset(element1, "Foo_Bar", "foo2") == "bar3" - assert ifcopenshell.util.element.get_pset(element2, "Foo_Bar", "foo2") == "bar2" + assert pset.HasProperties[0].is_a("IfcPropertyListValue") + assert len(pset.HasProperties[0].ListValues) == 3 + assert set(map(ifcopenshell.entity_instance.is_a, pset.HasProperties[0].ListValues)) == {"IfcIdentifier"} + assert list(map(operator.itemgetter(0), pset.HasProperties[0].ListValues)) == ["One", "Two", "Three"] diff --git a/src/ifcopenshell-python/test/api/pset/test_unassign_pset.py b/src/ifcopenshell-python/test/api/pset/test_unassign_pset.py new file mode 100644 index 0000000000..585a281713 --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset/test_unassign_pset.py @@ -0,0 +1,66 @@ +# 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 test.bootstrap +import ifcopenshell.api.pset +import ifcopenshell.util.element + + +class TestUnassignPset(test.bootstrap.IFC4): + def test_unassign_pset_from_last_occurrence_and_remove_rel(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + ifcopenshell.api.pset.assign_pset(self.file, elements, pset) + + ifcopenshell.api.pset.unassign_pset(self.file, elements, pset) + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + + def test_unassign_pset_from_non_last_occurrence_and_reuse_rel(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + rel = ifcopenshell.api.pset.assign_pset(self.file, elements, pset) + assert rel + + ifcopenshell.api.pset.unassign_pset(self.file, elements[1:], pset) + assert rel.RelatedObjects == (elements[0],) + + def test_unassign_non_last_pset_from_type(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + pset2 = self.file.create_entity("IfcPropertySet") + ifcopenshell.api.pset.assign_pset(self.file, elements, pset) + ifcopenshell.api.pset.assign_pset(self.file, elements, pset2) + + ifcopenshell.api.pset.unassign_pset(self.file, elements, pset) + assert pset.DefinesType == tuple() + assert set(pset2.DefinesType) == set(elements) + for element in elements: + assert element.HasPropertySets == (pset2,) + + def test_unassign_last_pset_from_type_and_set_prop_to_none(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + pset = self.file.create_entity("IfcPropertySet") + ifcopenshell.api.pset.assign_pset(self.file, elements, pset) + ifcopenshell.api.pset.unassign_pset(self.file, elements, pset) + assert pset.DefinesType == tuple() + for element in elements: + assert element.HasPropertySets is None + + +class TestUnassignPsetIFC2X3(test.bootstrap.IFC2X3, TestUnassignPset): + pass diff --git a/src/ifcopenshell-python/test/api/pset/test_unshare_pset.py b/src/ifcopenshell-python/test/api/pset/test_unshare_pset.py new file mode 100644 index 0000000000..846d2d5a54 --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset/test_unshare_pset.py @@ -0,0 +1,99 @@ +# 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 test.bootstrap +import ifcopenshell.api.pset +import ifcopenshell.api.root +import ifcopenshell.util.element +import ifcopenshell.guid + + +class TestUnsharePset(test.bootstrap.IFC4): + def test_unshare_pset_for_occurrence(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = elements + + new_psets = ifcopenshell.api.pset.unshare_pset(self.file, elements[:2], pset) + assert isinstance(new_psets, list) + assert len(new_psets) == 2 + + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 3 + + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_by_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + + def test_unshare_pset_for_type(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + elements[1].HasPropertySets = (pset,) + elements[2].HasPropertySets = (pset,) + + new_psets = ifcopenshell.api.pset.unshare_pset(self.file, elements[:2], pset) + assert isinstance(new_psets, list) + assert len(new_psets) == 2 + + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + + used_psets: set[ifcopenshell.entity_instance] = set() + for element in elements: + element_psets = element.HasPropertySets + assert len(element_psets) == 1 + used_psets.add(element_psets[0]) + + assert used_psets == set(psets) + + def test_unshare_pset_for_all_pset_elements(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + pset_id = pset.id() + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = elements + + new_psets = ifcopenshell.api.pset.unshare_pset(self.file, elements, pset) + # Original pset still exists and it's not orphaned. + pset = self.file.by_id(pset_id) + + assert isinstance(new_psets, list) + assert len(new_psets) == 2 + + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 3 + + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_by_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + + +class TestUnsharePsetIFC2X3(test.bootstrap.IFC2X3, TestUnsharePset): + pass 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 196a60f154..f7b1024b6b 100644 --- a/src/ifcopenshell-python/test/api/root/test_reassign_class.py +++ b/src/ifcopenshell-python/test/api/root/test_reassign_class.py @@ -16,9 +16,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import ifcopenshell.api.aggregate +import ifcopenshell.api.geometry +import ifcopenshell.api.spatial +import ifcopenshell.util.representation import test.bootstrap -import ifcopenshell.api.type +import ifcopenshell.api.pset import ifcopenshell.api.root +import ifcopenshell.api.type import ifcopenshell.util.element @@ -95,6 +100,102 @@ class TestReassignClass(test.bootstrap.IFC4): assert len(self.file.by_type("IfcWall")) == 0 assert len(self.file.by_type("IfcWallType")) == 0 + # Switching between occurrence / type classes. + def test_unassign_type_from_occurrences_if_switching_from_type_class_to_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) + + element_type = ifcopenshell.api.root.reassign_class(self.file, product=element_type, ifc_class="IfcSlab") + + # Type is unassigned. + assert ifcopenshell.util.element.get_type(element1) is None + assert ifcopenshell.util.element.get_type(element2) is None + assert len(self.file.by_type("IfcRelDefinesByType")) == 0 + + assert len(self.file.by_type("IfcWall")) == 2 + assert len(self.file.by_type("IfcSlab")) == 1 + + def test_unassign_type_from_element_if_switching_from_occurrence_class_to_type_class(self): + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlabType") + + # Type is unassigned. + assert ifcopenshell.util.element.get_types(element_type) == [] + assert len(self.file.by_type("IfcRelDefinesByType")) == 0 + + assert len(self.file.by_type("IfcWallType")) == 1 + assert len(self.file.by_type("IfcSlabType")) == 1 + + def test_unassigning_container_switching_from_occurrence_class_to_type_class(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + container = self.file.create_entity("IfcBuilding") + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=container) + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlabType") + + assert len(self.file.by_type("IfcRelContainedInSpatialStructure")) == 0 + assert len(self.file.by_type("IfcWall")) == 0 + assert len(self.file.by_type("IfcSlabType")) == 1 + + def test_unassigning_aggregate_switching_from_occurrence_class_to_type_class(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + aggregate = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab") + ifcopenshell.api.aggregate.assign_object(self.file, products=[element], relating_object=aggregate) + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlabType") + + assert len(self.file.by_type("IfcRelAggregates")) == 0 + assert len(self.file.by_type("IfcWall")) == 0 + assert len(self.file.by_type("IfcSlabType")) == 1 + + def test_keeping_psets_switching_from_occurrence_class_to_type_class(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, element, name="TestPset") + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlabType") + + assert ifcopenshell.util.element.get_pset(element, name="TestPset")["id"] == pset.id() + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + assert len(self.file.by_type("IfcWall")) == 0 + assert len(self.file.by_type("IfcSlabType")) == 1 + + def test_keeping_psets_switching_from_type_class_to_occurrence_class(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + pset = ifcopenshell.api.pset.add_pset(self.file, element, name="TestPset") + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlab") + + assert ifcopenshell.util.element.get_pset(element, name="TestPset")["id"] == pset.id() + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 1 + assert len(self.file.by_type("IfcWallType")) == 0 + assert len(self.file.by_type("IfcSlab")) == 1 + + def test_keeping_representations_switching_from_occurrence_class_to_type_class(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + context = self.file.create_entity("IfcGeometricRepresentationContext") + representation = self.file.create_entity("IfcShapeRepresentation", ContextOfItems=context) + ifcopenshell.api.geometry.assign_representation(self.file, element, representation) + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlabType") + + assert ifcopenshell.util.representation.get_representation(element, context=context) == representation + assert len(self.file.by_type("IfcWall")) == 0 + assert len(self.file.by_type("IfcSlabType")) == 1 + + def test_keeping_representations_switching_from_type_class_to_occurrence_class(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + context = self.file.create_entity("IfcGeometricRepresentationContext") + representation = self.file.create_entity("IfcShapeRepresentation", ContextOfItems=context) + representation.Items = [self.file.create_entity("IfcExtrudedAreaSolid")] + ifcopenshell.api.geometry.assign_representation(self.file, element, representation) + element = ifcopenshell.api.root.reassign_class(self.file, product=element, ifc_class="IfcSlab") + + assert len(self.file.by_type("IfcRepresentationMap")) == 0 + assert ifcopenshell.util.representation.get_representation(element, context=context) == representation + assert len(self.file.by_type("IfcWallType")) == 0 + assert len(self.file.by_type("IfcSlab")) == 1 + class TestReassignClassIFC2X3(test.bootstrap.IFC2X3, TestReassignClass): pass diff --git a/src/ifcopenshell-python/test/api/style/test_assign_representation_styles.py b/src/ifcopenshell-python/test/api/style/test_assign_representation_styles.py index 1995417e31..9527813291 100644 --- a/src/ifcopenshell-python/test/api/style/test_assign_representation_styles.py +++ b/src/ifcopenshell-python/test/api/style/test_assign_representation_styles.py @@ -103,6 +103,21 @@ class TestAssignRepresentationStyles(test.bootstrap.IFC4): assert item.StyledByItem[0].Styles[0].Styles == (style2, style3) assert len(self.file.by_type("IfcStyledItem")) == 1 + def test_assign_style_to_topology_representation(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + product_shape = self.file.create_entity("IfcProductDefinitionShape") + element.Representation = product_shape + + representation = self.file.create_entity("IfcTopologyRepresentation") + item = self.file.create_entity("IfcFace") + representation.Items = [item] + + style = self.file.create_entity("IfcSurfaceStyle") + ifcopenshell.api.style.assign_representation_styles( + self.file, styles=[style], shape_representation=representation + ) + assert item.StyledByItem[0].Styles == (style,) + class TestAssignRepresentationStylesIFC2X3(test.bootstrap.IFC2X3): def test_run(self): diff --git a/src/ifcopenshell-python/test/bootstrap.py b/src/ifcopenshell-python/test/bootstrap.py index 8fa9c5b20b..055ea8774a 100644 --- a/src/ifcopenshell-python/test/bootstrap.py +++ b/src/ifcopenshell-python/test/bootstrap.py @@ -16,6 +16,15 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import os +import sys + +if os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) == sys.path[0]: + # Don't import ifcopenshell from local directory, because it most likely + # does not contain the built binary + sys.path[0:1] = [] + + import pytest import ifcopenshell import ifcopenshell.api.project @@ -67,3 +76,8 @@ class IFC2X3: ifcopenshell.api.pre_listeners = {} ifcopenshell.api.post_listeners = {} + + +@pytest.fixture(autouse=True) +def file(request): + return ifcopenshell.open(os.path.join("test/fixtures", request.param)) diff --git a/src/ifcopenshell-python/test/files/basic.ifc b/src/ifcopenshell-python/test/files/basic.ifc new file mode 100644 index 0000000000..b38ea13997 --- /dev/null +++ b/src/ifcopenshell-python/test/files/basic.ifc @@ -0,0 +1,77 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('simple.ifc','2024-09-24T10:19:09+05:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.1-alpha240909-de29f0d','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('3kv235yMjDO9tHiTzD8QuS',$,'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((#8,#3,#4,#2)); +#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('28uvBpFyH6$wV_xdXBcxs5',$,'My Site',$,$,#54,$,$,$,$,$,$,$,$); +#37=IFCBUILDING('2O7wp_Y998t9AqwuADKLOw',$,'My Building',$,$,#60,$,$,$,$,$,$); +#43=IFCBUILDINGSTOREY('3ZWkJ5Z0f7ue2_fvi3MZMP',$,'My Storey',$,$,#66,$,$,$,$); +#49=IFCRELAGGREGATES('1pyuIX$Hf7WxKiPjDpddJO',$,$,$,#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('1ZwcrXBcT6tOIzpuPVSrMo',$,$,$,#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('0W0RC2ScLEPu85NSJp2qA9',$,$,$,#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); +#69=IFCACTUATOR('0IsfoyWNPD6fjgrV6Lb1eP',$,'Actuator',$,$,#85,#80,$,.ELECTRICACTUATOR.); +#70=IFCRELCONTAINEDINSPATIALSTRUCTURE('24TYZUJp1A_QOmCaw9tR$O',$,$,$,(#69),#43); +#71=IFCCARTESIANPOINTLIST3D(((-0.25,-0.25,-0.25),(-0.25,-0.25,0.25),(-0.25,0.25,-0.25),(-0.25,0.25,0.25),(0.25,-0.25,-0.25),(0.25,-0.25,0.25),(0.25,0.25,-0.25),(0.25,0.25,0.25))); +#72=IFCINDEXEDPOLYGONALFACE((1,2,4,3)); +#73=IFCINDEXEDPOLYGONALFACE((3,4,8,7)); +#74=IFCINDEXEDPOLYGONALFACE((7,8,6,5)); +#75=IFCINDEXEDPOLYGONALFACE((5,6,2,1)); +#76=IFCINDEXEDPOLYGONALFACE((3,7,5,1)); +#77=IFCINDEXEDPOLYGONALFACE((8,4,2,6)); +#78=IFCPOLYGONALFACESET(#71,$,(#72,#73,#74,#75,#76,#77),$); +#79=IFCSHAPEREPRESENTATION(#15,'Body','Tessellation',(#78)); +#80=IFCPRODUCTDEFINITIONSHAPE($,$,(#79)); +#81=IFCCARTESIANPOINT((0.,0.,0.)); +#82=IFCDIRECTION((0.,0.,1.)); +#83=IFCDIRECTION((1.,0.,0.)); +#84=IFCAXIS2PLACEMENT3D(#81,#82,#83); +#85=IFCLOCALPLACEMENT(#66,#84); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/geom/polygonal-face-tessellation.ifc b/src/ifcopenshell-python/test/fixtures/geom/polygonal-face-tessellation.ifc new file mode 100644 index 0000000000..68347315ea --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/geom/polygonal-face-tessellation.ifc @@ -0,0 +1,79 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [notYetAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'polygonal-face-tessellation.ifc', +/* time_stamp */ '2016-05-26T17:38:29', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +#1= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.01,#3,$); +#2= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3= IFCAXIS2PLACEMENT3D(#3058,#3059,#3060); +#4= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#5= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#6= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#7= IFCDIRECTION((1.0,0.0,0.0)); +#8= IFCDIRECTION((0.0,1.0,0.0)); +#9= IFCDIRECTION((0.0,0.0,1.0)); +#10= IFCCARTESIANPOINT((0.0,0.0)); +#11= IFCAXIS2PLACEMENT2D(#10,$); +#12= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#13= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#14= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +/* ------------------------------------------------------------------------- */ +/* polygonal face set geometry representation */ +#15= IFCCARTESIANPOINTLIST3D(((-10000.0,-10000.0,-10000.0),(10000.0,-10000.0,-10000.0),(10000.0,10000.0,-10000.0),(-10000.0,10000.0,-10000.0),(-10000.0,-10000.0,10000.0),(10000.0,-10000.0,10000.0),(10000.0,10000.0,10000.0),(-10000.0,10000.0,10000.0),(-5000.0,-5000.0,-5000.0),(5000.0,-5000.0,-5000.0),(5000.0,5000.0,-5000.0),(-5000.0,5000.0,-5000.0),(5000.0,-5000.0,10000.0),(-5000.0,-5000.0,10000.0),(5000.0,5000.0,10000.0),(-5000.0,5000.0,10000.0)),$); +#16= IFCINDEXEDPOLYGONALFACE((2,6,5,1)); +#17= IFCINDEXEDPOLYGONALFACE((3,7,6,2)); +#18= IFCINDEXEDPOLYGONALFACE((4,8,7,3)); +#19= IFCINDEXEDPOLYGONALFACE((1,5,8,4)); +#20= IFCINDEXEDPOLYGONALFACE((4,3,2,1)); +#21= IFCINDEXEDPOLYGONALFACEWITHVOIDS((6,7,8,5),((14,16,15,13))); +#22= IFCINDEXEDPOLYGONALFACE((13,10,9,14)); +#23= IFCINDEXEDPOLYGONALFACE((15,11,10,13)); +#24= IFCINDEXEDPOLYGONALFACE((16,12,11,15)); +#25= IFCINDEXEDPOLYGONALFACE((14,9,12,16)); +#26= IFCINDEXEDPOLYGONALFACE((9,10,11,12)); +#27= IFCPOLYGONALFACESET(#15,.T.,(#16,#17,#18,#19,#20,#21,#22,#23,#24,#25,#26),$); +#28= IFCSHAPEREPRESENTATION(#5,'Body','Tessellation',(#27)); +#29= IFCPRODUCTDEFINITIONSHAPE($,$,(#28)); +/* ------------------------------------------------------------------------- */ +/* assigned as shape representation to a proxy */ +#30= IFCBUILDINGELEMENTPROXY('1csV6umSb0px7vDxobGEN_',#3054,'NOTDEFINED',$,$,#32,#29,$,.NOTDEFINED.); +#31= IFCAXIS2PLACEMENT3D(#2,$,$); +#32= IFCLOCALPLACEMENT(#3047,#31); +#3047= IFCLOCALPLACEMENT($,#3); +/* ------------------------------------------------------------------------- */ +/* definition of the minimal building structure, units, owner history */ +#3048= IFCBUILDING('2tMIBeIVfClPSrbOosOJDI',#3054,'Grasshopper Building','GH Building',$,#3047,$,'GH Building',$,0.0,0.0,$); +#3049= IFCPERSONANDORGANIZATION(#3050,#3051,$); +#3050= IFCPERSON('redacted','redacted',$,$,$,$,$,$); +#3051= IFCORGANIZATION($,'redacted',$,$,$); +#3052= IFCAPPLICATION(#3053,'redacted','redacted','redacted'); +#3053= IFCORGANIZATION($,'redacted',$,$,$); +#3054= IFCOWNERHISTORY(#3049,#3052,$,.ADDED.,1464284014,$,$,1464284014); +#3055= IFCRELCONTAINEDINSPATIALSTRUCTURE('3_FDRX0_zCNwiRAZP_mk8l',#3054,'Building','Building Container for Elements',(#30),#3048); +#3056= IFCAXIS2PLACEMENT3D(#2,$,$); +#3058= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3059= IFCDIRECTION((0.0,0.0,1.0)); +#3060= IFCDIRECTION((1.0,0.0,0.0)); +#3061= IFCPROJECT('37E0t9DhPDuPXM8sgxexaw',#3054,'Grasshopper Project',$,$,$,$,(#1),#3062); +#3062= IFCUNITASSIGNMENT((#3063,#13,#14,#3064,#3065)); +#3063= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#3064= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#3065= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#3066= IFCRELAGGREGATES('1jnXpsMarCZQ$Xxt61uMb4',#3054,'Project Container','Project Container for Buildings',#3061,(#3048)); +#3070= IFCAXIS2PLACEMENT3D(#2,$,$); +ENDSEC; + +END-ISO-10303-21; + + diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-few.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-few.ifc index b39bc0dee1..1af110a368 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-few.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-few.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-10-02T14:16:07',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-10-02T14:16:07',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-many.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-many.ifc index f39c0cf0d3..8afbf3fa79 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-many.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-attr-too-many.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-10-02T14:16:07',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-10-02T14:16:07',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-derived-as-nil.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-derived-as-nil.ifc index 5638fb5b4c..52335e3e4a 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-derived-as-nil.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-derived-as-nil.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-11-23T13:41:45',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-11-23T13:41:45',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc2x3.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc2x3.ifc index 9a6eb58e05..5ec7f482e6 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc2x3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc2x3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2024-04-02T17:16:50',(),(),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); +FILE_NAME('','2024-04-02T17:16:50',(''),(''),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); FILE_SCHEMA(('IFC2X3')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4.ifc index eeca2cf84f..3db2b7f0a6 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2024-04-02T17:16:50',(),(),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); +FILE_NAME('','2024-04-02T17:16:50',(''),(''),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4x3.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4x3.ifc index 9a648490ef..8c9e3e2f61 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4x3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-guids-ifc4x3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2024-04-02T17:16:50',(),(),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); +FILE_NAME('','2024-04-02T17:16:50',(''),(''),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); FILE_SCHEMA(('IFC4X3_ADD2')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-group-0-rels-IFC2X3.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-group-0-rels-IFC2X3.ifc index a86dbd1c0a..2b002bdf1f 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-group-0-rels-IFC2X3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-group-0-rels-IFC2X3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-02-12T15:07:11',(),(),'IfcOpenShell v0.7.0-07ee62eb9','IfcOpenShell v0.7.0-07ee62eb9',''); +FILE_NAME('','2023-02-12T15:07:11',(''),(''),'IfcOpenShell v0.7.0-07ee62eb9','IfcOpenShell v0.7.0-07ee62eb9',''); FILE_SCHEMA(('IFC2X3')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-group-2-rels-IFC2X3.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-group-2-rels-IFC2X3.ifc index 30fa487f2d..752b975030 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-group-2-rels-IFC2X3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-group-2-rels-IFC2X3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-02-12T15:07:11',(),(),'IfcOpenShell v0.7.0-07ee62eb9','IfcOpenShell v0.7.0-07ee62eb9',''); +FILE_NAME('','2023-02-12T15:07:11',(''),(''),'IfcOpenShell v0.7.0-07ee62eb9','IfcOpenShell v0.7.0-07ee62eb9',''); FILE_SCHEMA(('IFC2X3')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-few.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-few.ifc index e1954b0d33..9ee33c82dc 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-few.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-few.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-10-02T14:16:07',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0'); +FILE_NAME('','2022-10-02T14:16:07',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0'); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-many.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-many.ifc index 216fc1b1bc..6afe9d0f0d 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-many.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-header-attr-too-many.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-10-02T14:16:07',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0','',''); +FILE_NAME('','2022-10-02T14:16:07',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0','',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-instead-of-list-str.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-instead-of-list-str.ifc new file mode 100644 index 0000000000..e2a245cb53 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-instead-of-list-str.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',$,('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('1iBd2lkrX61vZxF9AnXLa0',$,'My Project',$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-instead-of-str.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-instead-of-str.ifc new file mode 100644 index 0000000000..8041ce1068 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-instead-of-str.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc',$,('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('1iBd2lkrX61vZxF9AnXLa0',$,'My Project',$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-list.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-list.ifc new file mode 100644 index 0000000000..aaff65f140 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-header-wrong-type-list.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',(4, 5),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('1iBd2lkrX61vZxF9AnXLa0',$,'My Project',$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-entity.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-entity.ifc index 3788c16cd5..b6367d784d 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-entity.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-entity.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-10-01T16:31:47',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-10-01T16:31:47',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-enumeration.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-enumeration.ifc index 7709215248..c674ba65be 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-enumeration.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-enumeration.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-04-13T10:27:43',(),(),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); +FILE_NAME('','2023-04-13T10:27:43',(''),(''),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-simple-type.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-simple-type.ifc index 0dba7e4479..024776c312 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-simple-type.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-invalid-selected-simple-type.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-04-13T10:24:46',(),(),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); +FILE_NAME('','2023-04-13T10:24:46',(''),(''),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-nil-as-derived.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-nil-as-derived.ifc index 5638fb5b4c..52335e3e4a 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/fail-nil-as-derived.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-nil-as-derived.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-11-23T13:41:45',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-11-23T13:41:45',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-attr-count-ok.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-attr-count-ok.ifc index ec52fc3d70..c8ee3e8be0 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-attr-count-ok.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-attr-count-ok.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-10-02T14:16:07',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-10-02T14:16:07',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-complex-number-ifc4.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-complex-number-ifc4.ifc index a6cf5660d0..beece3f8e0 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-complex-number-ifc4.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-complex-number-ifc4.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-04-13T10:27:43',(),(),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); +FILE_NAME('','2023-04-13T10:27:43',(''),(''),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-derived-ok.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-derived-ok.ifc index a8372bab1e..3e5a95ca49 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-derived-ok.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-derived-ok.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2022-11-23T13:41:45',(),(),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); +FILE_NAME('','2022-11-23T13:41:45',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-group-1-rels-IFC2X3.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-group-1-rels-IFC2X3.ifc index 91ade03f52..851ba9cf91 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-group-1-rels-IFC2X3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-group-1-rels-IFC2X3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-02-12T15:07:11',(),(),'IfcOpenShell v0.7.0-07ee62eb9','IfcOpenShell v0.7.0-07ee62eb9',''); +FILE_NAME('','2023-02-12T15:07:11',(''),(''),'IfcOpenShell v0.7.0-07ee62eb9','IfcOpenShell v0.7.0-07ee62eb9',''); FILE_SCHEMA(('IFC2X3')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-header-valid.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-header-valid.ifc new file mode 100644 index 0000000000..681746bad6 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-header-valid.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('test.ifc','2024-08-30T12:34:53+05:00',('author','author email'),('organization','organization email'),'IfcOpenShell','Bonsai','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('1iBd2lkrX61vZxF9AnXLa0',$,'My Project',$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc2x3.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc2x3.ifc index 37d71d03e4..e23f8b68bc 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc2x3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc2x3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2024-04-02T17:16:50',(),(),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); +FILE_NAME('','2024-04-02T17:16:50',(''),(''),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); FILE_SCHEMA(('IFC2X3')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4.ifc index e839daaba6..5d18d57e95 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2024-04-02T17:16:50',(),(),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); +FILE_NAME('','2024-04-02T17:16:50',(''),(''),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4x3.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4x3.ifc index 7d7fac5b4f..fa950af3f8 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4x3.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-guids-ifc4x3.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2024-04-02T17:16:50',(),(),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); +FILE_NAME('','2024-04-02T17:16:50',(''),(''),'IfcOpenShell v0.7.0-e38eafd03','IfcOpenShell v0.7.0-e38eafd03',''); FILE_SCHEMA(('IFC4X3_ADD2')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-valid-selected-enumeration.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-valid-selected-enumeration.ifc index fa6e897d36..d62aec0fad 100644 --- a/src/ifcopenshell-python/test/fixtures/validate/pass-valid-selected-enumeration.ifc +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-valid-selected-enumeration.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); -FILE_NAME('','2023-04-13T10:27:43',(),(),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); +FILE_NAME('','2023-04-13T10:27:43',(''),(''),'IfcOpenShell v0.7.0-198fa67cc','IfcOpenShell v0.7.0-198fa67cc',''); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; diff --git a/src/ifcopenshell-python/test/geom/geometry_tests.py b/src/ifcopenshell-python/test/geom/geometry_tests.py new file mode 100644 index 0000000000..0d77f505ed --- /dev/null +++ b/src/ifcopenshell-python/test/geom/geometry_tests.py @@ -0,0 +1,72 @@ +# 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 +import ifcopenshell.geom +import test.bootstrap +from ifcopenshell.util.shape_builder import ShapeBuilder, V + + +class TestCreatingShapes(test.bootstrap.IFC4): + def test_create_IfcCirle(self): + builder = ShapeBuilder(self.file) + item = builder.circle(radius=1.0) + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, item) + assert shape.verts + + def test_create_IfcEllipse(self): + builder = ShapeBuilder(self.file) + item = self.file.create_entity( + "IfcEllipse", Position=builder.create_axis2_placement_2d(), SemiAxis1=1.0, SemiAxis2=0.5 + ) + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, item) + assert shape.verts + + def test_create_IfcLine(self): + item = self.file.create_entity( + "IfcLine", + Pnt=self.file.create_entity("IfcCartesianPoint", (0.0, 0.0)), + Dir=self.file.create_entity( + "IfcVector", Orientation=self.file.create_entity("IfcDirection", (1.0, 0.0)), Magnitude=1.0 + ), + ) + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, item) + assert shape.verts + + def test_create_IfcBSplineCurve(self): + points = [ + (6.4062, 19.4575766757, 13.352536093), + (5.8477429418, 19.9080686796, 14.1328111323), + (3.6139147091, 20.3256826827, 14.8561398035), + (1.1790137971e-05, 20.2853841412, 14.7863406821), + (1.1790137971e-05, 18.5102818197, 11.7117732726), + (1.16078600148, 18.0665062393, 10.9431314203), + ] + item = self.file.create_entity( + "IfcBSplineCurveWithKnots", + Degree=3, + ControlPointsList=[self.file.create_entity("IfcCartesianPoint", p) for p in points], + KnotMultiplicities=(4, 1, 1, 4), + Knots=(0.0, 1 / 3, 2 / 3, 1.0), + ) + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, item) + assert shape.verts diff --git a/src/ifcopenshell-python/test/geom/polyhedral_shapes.py b/src/ifcopenshell-python/test/geom/polyhedral_shapes.py new file mode 100644 index 0000000000..56c39c4c3b --- /dev/null +++ b/src/ifcopenshell-python/test/geom/polyhedral_shapes.py @@ -0,0 +1,54 @@ +import collections +import ifcopenshell +import ifcopenshell.geom + +import pytest + +from test.bootstrap import file + + +@pytest.mark.parametrize("file", ["geom/polygonal-face-tessellation.ifc"], indirect=True) +def test_polyhedral_setting(file): + s = ifcopenshell.geom.settings() + # default is triangle mesh + assert s.get("triangulation-type") == ifcopenshell.ifcopenshell_wrapper.TRIANGLE_MESH + s = ifcopenshell.geom.settings(TRIANGULATION_TYPE=ifcopenshell.ifcopenshell_wrapper.POLYHEDRON_WITHOUT_HOLES) + obj = ifcopenshell.geom.create_shape(s, file[30]) + # outer: + # - top + # - left + # - bottom + # - right + # - back; + # inner: + # - top + # - left + # - bottom + # - right + # - back; + # front: + # - 8 triangles + # --------------------- + + # 18 facets + assert len(obj.geometry.faces) == 18 + s = ifcopenshell.geom.settings(TRIANGULATION_TYPE=ifcopenshell.ifcopenshell_wrapper.POLYHEDRON_WITH_HOLES) + obj = ifcopenshell.geom.create_shape(s, file[30]) + # outer: + # - top + # - left + # - bottom + # - right + # - back; + # inner: + # - top + # - left + # - bottom + # - right + # - back; + # front: + # - single facet with 2 bounds + # --------------------- + + # 11 facets + assert len(obj.geometry.faces) == 11 + # 10 facets with 1 bound, 1 facet with 2 bounds + assert sorted(collections.Counter(map(len, obj.geometry.faces)).items()) == [(1, 10), (2, 1)] diff --git a/src/ifcopenshell-python/test/test_create_shape.py b/src/ifcopenshell-python/test/test_create_shape.py index aec58b4cdc..ad91dbbbf1 100644 --- a/src/ifcopenshell-python/test/test_create_shape.py +++ b/src/ifcopenshell-python/test/test_create_shape.py @@ -1,11 +1,15 @@ import pytest +import test.bootstrap import ifcopenshell -import ifcopenshell.api.unit -import ifcopenshell.api.root import ifcopenshell.api.context -import ifcopenshell.api.project -import ifcopenshell.geom import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import ifcopenshell.geom +import ifcopenshell.ifcopenshell_wrapper as W +import ifcopenshell.util.shape +from ifcopenshell.util.shape_builder import ShapeBuilder, V from typing import get_args @@ -19,6 +23,7 @@ class TestGeomSettings: assert settings.get("use-python-opencascade") is False assert "USE_PYTHON_OPENCASCADE = False" in repr(settings) + # Testing both new and old ways of setting geometry settings. if ifcopenshell.geom.has_occ: settings.set("use-python-opencascade", True) settings.set(settings.USE_PYTHON_OPENCASCADE, True) @@ -46,6 +51,77 @@ class TestGeomSettings: assert "USE_PYTHON_OPENCASCADE" not in repr(settings) +class TestTriangulationAttributes(test.bootstrap.IFC4): + def test_faces_representation_item_ids(self): + ifc_file = ifcopenshell.file() + ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject", name="Test") + context = ifcopenshell.api.context.add_context(ifc_file, context_type="Model") + + builder = ShapeBuilder(ifc_file) + extrusion = builder.extrude(builder.rectangle(), magnitude=1.0) + representation = builder.get_representation(context, extrusion) + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, representation) + faces_item_ids = ifcopenshell.util.shape.get_faces_representation_item_ids(shape) + faces = ifcopenshell.util.shape.get_faces(shape) + assert set(faces_item_ids) == {extrusion.id()} + assert len(faces) == 12 # Cube has 12 tris. + assert len(faces_item_ids) == len(faces) + + edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(shape) + edges = ifcopenshell.util.shape.get_edges(shape) + assert set(edges_item_ids) == {extrusion.id()} + assert len(edges) == 12 # Cube has 12 edges. + assert len(edges_item_ids) == len(edges) + + def test_curve_representation_item_ids(self): + ifc_file = ifcopenshell.file() + ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject", name="Test") + context = ifcopenshell.api.context.add_context(ifc_file, context_type="Model") + + builder = ShapeBuilder(ifc_file) + curve = builder.rectangle() + representation = builder.get_representation(context, curve) + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", W.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, representation) + + faces_item_ids = ifcopenshell.util.shape.get_faces_representation_item_ids(shape) + assert len(faces_item_ids) == 0 + + edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(shape) + edges = ifcopenshell.util.shape.get_edges(shape) + assert set(edges_item_ids) == {curve.id()} + assert len(edges) == 4 + assert len(edges_item_ids) == len(edges) + + def test_mixed_representation_item_ids(self): + ifc_file = ifcopenshell.file() + ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject", name="Test") + context = ifcopenshell.api.context.add_context(ifc_file, context_type="Model") + + builder = ShapeBuilder(ifc_file) + curve = builder.rectangle() + + fill = ifc_file.create_entity("IfcAnnotationFillArea", builder.rectangle()) + representation = builder.get_representation(context, (curve, fill)) + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", W.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, representation) + + faces_item_ids = ifcopenshell.util.shape.get_faces_representation_item_ids(shape) + faces = ifcopenshell.util.shape.get_faces(shape) + assert len(faces) == 2 # Fill area will produce a triangulated face. + assert set(faces_item_ids) == {fill.id()} + assert len(faces_item_ids) == len(faces) + + edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(shape) + edges = ifcopenshell.util.shape.get_edges(shape) + assert set(edges_item_ids) == {fill.id(), curve.id()} + assert len(edges) == 8 # 4 edges rectangle curve + 4 edges fill area + assert len(edges_item_ids) == len(edges) + + class TestAssignObject: def test_no_welding_on_distinct_items(self): self.file = ifcopenshell.api.project.create_file() diff --git a/src/ifcopenshell-python/test/test_entity_instance.py b/src/ifcopenshell-python/test/test_entity_instance.py new file mode 100644 index 0000000000..a07c9cdade --- /dev/null +++ b/src/ifcopenshell-python/test/test_entity_instance.py @@ -0,0 +1,60 @@ +# 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 pytest +import test.bootstrap +import ifcopenshell +import ifcopenshell.api +import ifcopenshell.util.element + + +class TestGetInfo2(test.bootstrap.IFC4): + def test_instance_attribute(self): + brep = self.file.create_entity("IfcFacetedBrep") + shell = self.file.create_entity("IfcClosedShell") + brep.Outer = shell + assert brep.get_info_2(recursive=True) == { + "Outer": {"CfsFaces": None, "id": 2, "type": "IfcClosedShell"}, + "id": 1, + "type": "IfcFacetedBrep", + } + + def test_aggregate_of_instance_attribute(self): + shell = self.file.create_entity("IfcClosedShell") + faces = [self.file.create_entity("IfcFace") for i in range(3)] + shell.CfsFaces = faces + assert shell.get_info_2(recursive=True)["CfsFaces"] == ( + {"Bounds": None, "id": 2, "type": "IfcFace"}, + {"Bounds": None, "id": 3, "type": "IfcFace"}, + {"Bounds": None, "id": 4, "type": "IfcFace"}, + ) + + def test_aggregate_of_aggregate_of_instance_attribute(self): + surface = self.file.create_entity("IfcBSplineSurfaceWithKnots") + pp = [self.file.create_entity("IfcCartesianPoint", [float(i)]) for i in range(4)] + surface.ControlPointsList = [pp[:2], pp[2:]] + assert surface.get_info_2(recursive=True)["ControlPointsList"] == ( + ( + {"Coordinates": (0.0,), "id": 2, "type": "IfcCartesianPoint"}, + {"Coordinates": (1.0,), "id": 3, "type": "IfcCartesianPoint"}, + ), + ( + {"Coordinates": (2.0,), "id": 4, "type": "IfcCartesianPoint"}, + {"Coordinates": (3.0,), "id": 5, "type": "IfcCartesianPoint"}, + ), + ) diff --git a/src/ifcopenshell-python/test/test_file.py b/src/ifcopenshell-python/test/test_file.py index 60b9688f86..31c77cd6e6 100644 --- a/src/ifcopenshell-python/test/test_file.py +++ b/src/ifcopenshell-python/test/test_file.py @@ -279,3 +279,10 @@ class TestFile(test.bootstrap.IFC4): element = self.file.createIfcWall() g = ifcopenshell.file.from_string(self.file.wrapped_data.to_string()) assert g.by_id(1).is_a("IfcWall") + + def test_assigning_header(self): + f = ifcopenshell.file(schema="IFC4") + f.header.file_name.name = "test" + g = ifcopenshell.file(schema="IFC4") + g.assign_header_from(f) + assert g.header.file_name.name == "test" diff --git a/src/ifcopenshell-python/test/test_sql.py b/src/ifcopenshell-python/test/test_sql.py new file mode 100644 index 0000000000..194bc7cf86 --- /dev/null +++ b/src/ifcopenshell-python/test/test_sql.py @@ -0,0 +1,70 @@ +# 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 pytest +import test.bootstrap +import ifcopenshell +import ifcpatch +from pathlib import Path + +TEST_FILE = Path(__file__).parent / "files" / "basic.ifc" +SQLITE_PATH = None + + +def get_ifc_sqlite() -> ifcopenshell.sqlite: + global SQLITE_PATH + if SQLITE_PATH is None: + SQLITE_PATH = ifcpatch.execute( + {"file": ifcopenshell.open(TEST_FILE), "recipe": "Ifc2Sql", "arguments": ["sqlite"]} + ) + assert isinstance(SQLITE_PATH, str) + ifc_sqlite = ifcopenshell.open(SQLITE_PATH) + assert isinstance(ifc_sqlite, ifcopenshell.sqlite) + return ifc_sqlite + + +class TestFile: + def test_run(self): + ifc_sqlite = get_ifc_sqlite() + assert ifc_sqlite.schema == "IFC4" + + def test_by_id(self): + ifc_sqlite = get_ifc_sqlite() + assert (element := ifc_sqlite.by_id(1)) + assert str(element) == "#1=IFCPROJECT('3kv235yMjDO9tHiTzD8QuS',$,'My Project',$,$,$,$,(#14,#26),#9);" + + def test_by_type(self): + ifc_sqlite = get_ifc_sqlite() + assert (elements := ifc_sqlite.by_type("IfcProject")) + assert str(elements[0]) == "#1=IFCPROJECT('3kv235yMjDO9tHiTzD8QuS',$,'My Project',$,$,$,$,(#14,#26),#9);" + + +class TestEntity: + def test_getattr(self): + ifc_sqlite = get_ifc_sqlite() + assert (element := ifc_sqlite.by_id(1)) + assert element.Name == "My Project" + + def test_setattr(self): + ifc_sqlite = get_ifc_sqlite() + assert (element := ifc_sqlite.by_id(1)) + assert element.Name == "My Project" + element.Name = "Foo" + assert element.Name == "Foo" + element.Name = "My Project" + assert element.Name == "My Project" diff --git a/src/ifcopenshell-python/test/test_stream.py b/src/ifcopenshell-python/test/test_stream.py new file mode 100644 index 0000000000..f9c3b1c710 --- /dev/null +++ b/src/ifcopenshell-python/test/test_stream.py @@ -0,0 +1,47 @@ +# 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 pytest +import test.bootstrap +import ifcopenshell +from pathlib import Path + +TEST_FILE = Path(__file__).parent / "files" / "basic.ifc" + + +class TestFile: + def test_run(self): + stream_file = ifcopenshell.open(TEST_FILE, should_stream=True) + assert stream_file.schema == "IFC4" + + def test_by_id(self): + stream_file = ifcopenshell.open(TEST_FILE, should_stream=True) + assert (element := stream_file.by_id(1)) + assert str(element) == "#1=IFCPROJECT('3kv235yMjDO9tHiTzD8QuS',$,'My Project',$,$,$,$,(#14,#26),#9);" + + def test_by_type(self): + stream_file = ifcopenshell.open(TEST_FILE, should_stream=True) + assert (elements := stream_file.by_type("IfcProject")) + assert str(elements[0]) == "#1=IFCPROJECT('3kv235yMjDO9tHiTzD8QuS',$,'My Project',$,$,$,$,(#14,#26),#9);" + + +class TestEntity: + def test_getattr(self): + stream_file = ifcopenshell.open(TEST_FILE, should_stream=True) + assert (element := stream_file.by_id(1)) + assert element.Name == "My Project" diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 04d8da4a64..759f6ff0f6 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.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.api.profile import pytest import test.bootstrap import ifcopenshell.api.void @@ -260,6 +261,28 @@ class TestGetPropertiesIFC4(test.bootstrap.IFC4): } +class TestGetElementsUsingPset(test.bootstrap.IFC4): + def test_run(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + pset = self.file.create_entity("IfcPropertySet") + ifcopenshell.api.pset.assign_pset(self.file, [element, element_type], pset) + assert subject.get_elements_by_pset(pset) == {element, element_type} + + def test_get_material_for_pset(self): + material = ifcopenshell.api.material.add_material(self.file) + pset = ifcopenshell.api.pset.add_pset(self.file, material, "FooBar") + assert subject.get_elements_by_pset(pset) == {material} + + def test_get_profile_for_pset(self): + profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, ifc_class="IfcRectangleProfileDef") + pset = ifcopenshell.api.pset.add_pset(self.file, profile, "FooBar") + assert subject.get_elements_by_pset(pset) == {profile} + + +class TestGetElementsUsingPsetIFC2X3(test.bootstrap.IFC2X3, TestGetElementsUsingPset): ... + + class TestGetPredefinedTypeIFC4(test.bootstrap.IFC4): def test_getting_an_element_predefined_type(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index a20372a9d7..4cb29c0523 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -28,6 +28,7 @@ import ifcopenshell.api.material import ifcopenshell.api.geometry import ifcopenshell.api.aggregate import ifcopenshell.api.group +import ifcopenshell.api.sequence import ifcopenshell.util.selector as subject import ifcopenshell.util.placement import ifcopenshell.util.pset @@ -352,3 +353,53 @@ class TestSetElementValue(test.bootstrap.IFC4): layer.Material = material subject.set_element_value(self.file, layer, "Material.Name", "Foo") assert material.Name == "Foo" + + +class TestSetElementValuePredefinedType(test.bootstrap.IFC4): + def test_setting_an_element_predefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + subject.set_element_value(self.file, element, "predefined_type", "LIGHTDOME") + assert element.PredefinedType == "LIGHTDOME" + assert element.ObjectType == None + + def test_setting_an_element_predefined_type_to_none(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + subject.set_element_value(self.file, element, "predefined_type", None) + assert element.PredefinedType == None + assert element.ObjectType == None + + def test_setting_an_element_userdefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + subject.set_element_value(self.file, element, "predefined_type", "FOOBAR") + assert element.PredefinedType == "USERDEFINED" + assert element.ObjectType == "FOOBAR" + + def test_setting_an_element_inherited_predefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindowType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + subject.set_element_value(self.file, element, "predefined_type", "LIGHTDOME") + assert element_type.PredefinedType == "LIGHTDOME" + assert element_type.ElementType == None + assert element.PredefinedType == None + assert element.ObjectType == None + + def test_setting_an_element_inherited_predefined_type_to_none(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindowType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + subject.set_element_value(self.file, element, "predefined_type", None) + assert element_type.PredefinedType == "NOTDEFINED" + assert element_type.ElementType == None + assert element.PredefinedType == None + assert element.ObjectType == None + + def test_setting_an_element_inherited_userdefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindowType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + subject.set_element_value(self.file, element, "predefined_type", "FOOBAR") + assert element_type.PredefinedType == "USERDEFINED" + assert element_type.ElementType == "FOOBAR" + assert element.PredefinedType == None + assert element.ObjectType == None diff --git a/src/ifcparse/IfcFile.cpp b/src/ifcparse/IfcFile.cpp index 830754444f..79c3d25f6f 100644 --- a/src/ifcparse/IfcFile.cpp +++ b/src/ifcparse/IfcFile.cpp @@ -130,10 +130,48 @@ namespace { if (aggregate_storage.which() == 0) { aggregate_storage = std::vector>{ v }; } else { - auto* vec_ptr = boost::get>>(&aggregate_storage); - if (vec_ptr) { + if (auto* vec_ptr = boost::get>>(&aggregate_storage)) { vec_ptr->push_back(v); } else { + if constexpr (std::is_same_v, int>) { + auto* vec_ptr2 = boost::get>(&aggregate_storage); + if (vec_ptr2) { + // double[] + int + vec_ptr2->push_back((double) v); + } + } + if constexpr (std::is_same_v, double>) { + auto* vec_ptr2 = boost::get>(&aggregate_storage); + if (vec_ptr2) { + // int[] -> double[] + double + std::vector ps(vec_ptr2->begin(), vec_ptr2->end()); + ps.push_back(v); + aggregate_storage = ps; + } + } + + if constexpr (std::is_same_v, std::vector>) { + auto* vec_ptr2 = boost::get>>(&aggregate_storage); + if (vec_ptr2) { + // double[][] + int[] + std::vector vd(v.begin(), v.end()); + vec_ptr2->push_back(vd); + } + } + if constexpr (std::is_same_v, std::vector>) { + auto* vec_ptr2 = boost::get>>(&aggregate_storage); + if (vec_ptr2) { + // int[][] -> double[][] + double[] + std::vector> vvd; + for (auto& vv : *vec_ptr2) { + std::vector vd(vv.begin(), vv.end()); + vvd.push_back(vd); + } + vvd.push_back(v); + aggregate_storage = vvd; + } + } + // @todo would be cool if we can trace this back to file offset auto current = boost::apply_visitor([](auto v) { if constexpr (!std::is_same_v) { @@ -145,6 +183,7 @@ namespace { return std::string{}; } }, aggregate_storage); + Logger::Error("Inconsistent aggregate valuation while attempting to append " + std::string(typeid(decltype(v)).name()) + " to an aggregate of " + current); // @todo boolean -> logical upgrade diff --git a/src/ifcparse/IfcFile.h b/src/ifcparse/IfcFile.h index 4d663eeb08..a61c9f94b7 100644 --- a/src/ifcparse/IfcFile.h +++ b/src/ifcparse/IfcFile.h @@ -42,7 +42,8 @@ class IFC_PARSE_API file_open_status { SUCCESS, READ_ERROR, NO_HEADER, - UNSUPPORTED_SCHEMA + UNSUPPORTED_SCHEMA, + INVALID_SYNTAX }; private: diff --git a/src/ifcparse/IfcGlobalId.cpp b/src/ifcparse/IfcGlobalId.cpp index d89e439a64..e6c77e67c5 100644 --- a/src/ifcparse/IfcGlobalId.cpp +++ b/src/ifcparse/IfcGlobalId.cpp @@ -87,8 +87,12 @@ void expand(const std::string& s, std::vector& v) { } } -// A random number generator for the UUID -static boost::uuids::basic_random_generator gen; +// Define the macro to handle different Boost versions +#if BOOST_VERSION >= 108600 + static boost::uuids::basic_random_generator gen; +#else + static boost::uuids::basic_random_generator gen; +#endif IfcParse::IfcGlobalId::IfcGlobalId() { uuid_data_ = gen(); diff --git a/src/ifcparse/IfcLogger.cpp b/src/ifcparse/IfcLogger.cpp index 1b1d9ee437..ece1876521 100644 --- a/src/ifcparse/IfcLogger.cpp +++ b/src/ifcparse/IfcLogger.cpp @@ -114,6 +114,12 @@ void json_message(T& out, const boost::optional& c property_tree.put(time_string, string_as(get_time())); boost::property_tree::write_json(out, property_tree, false); + + // Append a newline after the JSON object if the Boost version is 1.86 or higher +#if BOOST_VERSION >= 108600 + out << '\n'; +#endif + } } // namespace diff --git a/src/ifcparse/IfcParse.cpp b/src/ifcparse/IfcParse.cpp index 3ba0ec0ed5..c2f2cf5119 100644 --- a/src/ifcparse/IfcParse.cpp +++ b/src/ifcparse/IfcParse.cpp @@ -709,7 +709,7 @@ void IfcParse::IfcFile::load(unsigned entity_instance_name, const IfcParse::enti load(entity_instance_name, entity, context.push(), attribute_index == -1 ? (int) attribute_index_within_data : attribute_index); } else { return_value++; - if (TokenFunc::isIdentifier(next)) { + if (TokenFunc::isIdentifier(next) && entity) { register_inverse(entity_instance_name, entity, next, attribute_index == -1 ? attribute_index_within_data : attribute_index); } @@ -1149,7 +1149,15 @@ void IfcUtil::IfcBaseClass::set_attribute_value(size_t i, const T& t) { apply_individual_instance_visitor(current_attribute, (int) i).apply(visitor); } - data_.storage_.set(i, t); + if constexpr (std::is_pointer_v) { + if (t) { + data_.storage_.set(i, t); + } else { + data_.storage_.set(i, Blank{}); + } + } else { + data_.storage_.set(i, t); + } auto new_attribute = data_.get_attribute_value(i); if (file_ != nullptr) { @@ -1370,6 +1378,7 @@ void IfcFile::initialize_(IfcParse::IfcSpfStream* s) { } if (next_token.type == Token_NONE) { + good_ = file_open_status::INVALID_SYNTAX; break; } @@ -1380,58 +1389,60 @@ void IfcFile::initialize_(IfcParse::IfcSpfStream* s) { delete tokens; - auto resolve_instance = [this](auto inst, int ref, int refattr) { - IfcUtil::IfcBaseClass* ptr; - if constexpr (std::is_same_v) { - entity_by_id_t::const_iterator it = byid_.find(inst); - if (it == byid_.end()) { - Logger::Error("Instance reference #" + std::to_string(inst) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found"); - ptr = nullptr; - } else { - ptr = it->second; + for (const auto& p : references_to_resolve) { + 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)) { + entity_by_id_t::const_iterator 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"); + } else { + byid_[p.first.name_]->data().storage_.set(p.first.index_, it->second); + } + } else if (auto* inst = boost::get(v)) { + byid_[p.first.name_]->data().storage_.set(p.first.index_, *inst); } - } else { - ptr = inst; - } - return ptr; - }; - - for (auto& p : references_to_resolve) { - boost::apply_visitor([this, &resolve_instance, &p](auto& v) { - if constexpr (std::is_same_v, reference_or_simple_type>) { - auto inst = boost::apply_visitor([p, &resolve_instance](auto x) { return resolve_instance(x, p.first.name_, p.first.index_); }, v); - if (inst) { - byid_[p.first.name_]->data().storage_.set(p.first.index_, inst); - } - } else if constexpr (std::is_same_v, std::vector>) { - aggregate_of_instance::ptr instances(new aggregate_of_instance); - instances->reserve(v.size()); - for (auto& vi : v) { - auto inst = boost::apply_visitor([p, &resolve_instance](auto x) { return resolve_instance(x, p.first.name_, p.first.index_); }, vi); - if (inst) { - instances->push(inst); + } else if (auto* v = boost::get>(&p.second)) { + aggregate_of_instance::ptr instances(new aggregate_of_instance); + instances->reserve(v->size()); + for (const auto& vi : *v) { + if (auto* name = boost::get(&vi)) { + entity_by_id_t::const_iterator 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"); + } else { + instances->push(it->second); } + } else if (auto* inst = boost::get(&vi)) { + instances->push(*inst); } - byid_[p.first.name_]->data().storage_.set(p.first.index_, instances); - } else if constexpr (std::is_same_v, std::vector>>) { - aggregate_of_aggregate_of_instance::ptr instances(new aggregate_of_aggregate_of_instance); - for (auto& vi : v) { - std::vector inner; - for (auto& vii : vi) { - auto inst = boost::apply_visitor([p, &resolve_instance](auto x) { return resolve_instance(x, p.first.name_, p.first.index_); }, vii); - if (inst) { - inner.push_back(inst); + } + byid_[p.first.name_]->data().storage_.set(p.first.index_, instances); + } else if (auto* v = boost::get>>(&p.second)) { + aggregate_of_aggregate_of_instance::ptr instances(new aggregate_of_aggregate_of_instance); + for (const auto& vi : *v) { + std::vector inner; + for (const auto& vii : vi) { + if (auto* name = boost::get(&vii)) { + entity_by_id_t::const_iterator 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"); + } else { + inner.push_back(it->second); } + } else if (auto* inst = boost::get(&vii)) { + inner.push_back(*inst); } - instances->push(inner); } - byid_[p.first.name_]->data().storage_.set(p.first.index_, instances); - } else { - // static_assert(false, "Inconsistent type"); + instances->push(inner); } - }, p.second); + byid_[p.first.name_]->data().storage_.set(p.first.index_, instances); + } } + Logger::Status("Done resolving references"); + references_to_resolve.clear(); } @@ -2201,7 +2212,7 @@ void IfcFile::setDefaultHeaderValues() { const std::string empty_string; std::vector file_description; std::vector schema_identifiers; - std::vector empty_vector; + std::vector string_vector = {""}; file_description.push_back("ViewDefinition [CoordinationView]"); if (schema() != nullptr) { @@ -2213,8 +2224,8 @@ void IfcFile::setDefaultHeaderValues() { header().file_name().name(empty_string); header().file_name().time_stamp(createTimestamp()); - header().file_name().author(empty_vector); - header().file_name().organization(empty_vector); + header().file_name().author(string_vector); + header().file_name().organization(string_vector); header().file_name().preprocessor_version("IfcOpenShell " IFCOPENSHELL_VERSION); header().file_name().originating_system("IfcOpenShell " IFCOPENSHELL_VERSION); header().file_name().authorization(empty_string); diff --git a/src/ifcparse/IfcSchema.h b/src/ifcparse/IfcSchema.h index 5d31a728df..3dedd47a8e 100644 --- a/src/ifcparse/IfcSchema.h +++ b/src/ifcparse/IfcSchema.h @@ -495,8 +495,8 @@ class IFC_PARSE_API schema_definition { return *iter; } - const declaration* declaration_by_name(int name) const { - return declarations_[name]; + const declaration* declaration_by_name(size_t name) const { + return declarations_.at(name); } const std::vector& declarations() const { return declarations_; } diff --git a/src/ifcparse/IfcSpfHeader.h b/src/ifcparse/IfcSpfHeader.h index f07faeacf9..f71dacfa68 100644 --- a/src/ifcparse/IfcSpfHeader.h +++ b/src/ifcparse/IfcSpfHeader.h @@ -52,6 +52,10 @@ class IFC_PARSE_API HeaderEntity { return data_.size(); } + AttributeValue getArgument(size_t index) const { + return data_.get_attribute_value(index); + } + std::string toString(bool upper = false) const { std::stringstream stream; stream << datatype_; diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py index 3b7f49de9c..f9fdc9727a 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py @@ -136,21 +136,21 @@ class Patcher: if material.is_a("IfcMaterial"): materials = [] elif material.is_a("IfcMaterialLayerSet"): - for idx, item in enumerate(material.MaterialLayers): + for idx, item in enumerate(material.MaterialLayers or []): material = item.Material properties.append([i, "IFC Material", f"Layer {idx + 1} Name", getattr(item, "Name", None)]) properties.append([i, "IFC Material", f"Layer {idx + 1} Material", material.Name]) if category := getattr(material, "Category", None): properties.append([i, "IFC Material", f"Layer {idx + 1} Category", category]) elif material.is_a("IfcMaterialProfileSet"): - for idx, item in enumerate(material.MaterialProfiles): + for idx, item in enumerate(material.MaterialProfiles or []): material = item.Material properties.append([i, "IFC Material", f"Profile {idx + 1} Name", item.Name]) properties.append([i, "IFC Material", f"Profile {idx + 1} Material", material.Name]) if category := getattr(material, "Category", None): properties.append([i, "IFC Material", f"Profile {idx + 1} Category", category]) elif material.is_a("IfcMaterialConstituentSet"): - for idx, item in enumerate(material.MaterialConstituents): + for idx, item in enumerate(material.MaterialConstituents or []): material = item.Material properties.append([i, "IFC Material", f"Constituent {idx + 1} Name", item.Name]) properties.append([i, "IFC Material", f"Constituent {idx + 1} Material", material.Name]) diff --git a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py index b3e18714d0..3114d8620a 100644 --- a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py +++ b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py @@ -28,11 +28,13 @@ import numpy as np import multiprocessing import ifcopenshell import ifcopenshell.geom -import ifcopenshell.util.unit -import ifcopenshell.util.shape -import ifcopenshell.util.schema import ifcopenshell.util.attribute +import ifcopenshell.util.element import ifcopenshell.util.placement +import ifcopenshell.util.schema +import ifcopenshell.util.shape +import ifcopenshell.util.unit +from typing import Any SQLTypes = typing.Literal["SQLite", "MySQL"] @@ -91,8 +93,10 @@ class Patcher: .. code:: python - # Convert to SQLite - ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "Ifc2Sql", "arguments": ["sqlite"]}) + # Convert to SQLite, SQLite databse will be saved to a temporary file. + sqlite_temp_filepath = ifcpatch.execute( + {"input": "input.ifc", "file": model, "recipe": "Ifc2Sql", "arguments": ["sqlite"]} + ) """ self.src = src self.file = file @@ -103,7 +107,7 @@ class Patcher: self.password = password self.database = database - def patch(self): + def patch(self) -> None: self.full_schema = True # Set true for ifcopenshell.sqlite self.is_strict = False self.should_expand = False # Set false for ifcopenshell.sqlite @@ -115,7 +119,7 @@ class Patcher: self.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(self.file.schema) if self.sql_type == "sqlite": - tmp = tempfile.NamedTemporaryFile(delete=False) + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ifcsqlite") db_file = tmp.name self.db = sqlite3.connect(db_file) self.c = self.db.cursor() @@ -173,7 +177,7 @@ class Patcher: self.db.commit() self.db.close() - def create_geometry(self): + def create_geometry(self) -> None: self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) self.shape_rows = {} @@ -237,9 +241,8 @@ class Patcher: self.shape_rows[shape.id] = [shape.id, float(x), float(y), float(z), m.tobytes(), shape.geometry.id] if not iterator.next(): break - print("Done creating geometry") - def create_id_map(self): + def create_id_map(self) -> None: if self.sql_type == "sqlite": statement = ( "CREATE TABLE IF NOT EXISTS id_map (ifc_id integer PRIMARY KEY NOT NULL UNIQUE, ifc_class text);" @@ -254,7 +257,7 @@ class Patcher: """ self.c.execute(statement) - def create_metadata(self): + def create_metadata(self) -> None: # There is no "standard" SQL serialisation, so we propose a convention # of a "metadata" table to hold high level metadata. This includes the # preprocessor field to uniquely identify the "variant" of SQL schema @@ -278,7 +281,7 @@ class Patcher: self.c.execute(statement) self.c.execute("INSERT INTO metadata VALUES (%s, %s, %s);", metadata) - def create_pset_table(self): + def create_pset_table(self) -> None: statement = """ CREATE TABLE IF NOT EXISTS psets ( ifc_id integer NOT NULL, @@ -289,7 +292,7 @@ class Patcher: """ self.c.execute(statement) - def create_geometry_table(self): + def create_geometry_table(self) -> None: statement = """ CREATE TABLE IF NOT EXISTS shape ( ifc_id integer NOT NULL, @@ -319,7 +322,7 @@ class Patcher: self.c.execute(statement) - def create_sqlite_table(self, ifc_class, declaration): + def create_sqlite_table(self, ifc_class: str, declaration: ifcopenshell.ifcopenshell_wrapper.declaration) -> None: statement = f"CREATE TABLE IF NOT EXISTS {ifc_class} (" if self.should_expand: @@ -336,6 +339,7 @@ class Patcher: for i in range(0, total_attributes): attribute = declaration.attribute_by_index(i) primitive = ifcopenshell.util.attribute.get_primitive_type(attribute) + data_type = "TEXT" if primitive in ("string", "enum"): data_type = "TEXT" elif primitive in ("entity", "integer", "boolean"): @@ -346,8 +350,10 @@ class Patcher: data_type = "INTEGER" elif isinstance(primitive, tuple): data_type = "JSON" + elif primitive == "binary": + data_type = "TEXT" else: - print(attribute, primitive) # Not implemented? + print("Possibly not implemented attribute data type:", attribute, primitive) if not self.is_strict or derived[i]: optional = "" else: @@ -357,10 +363,9 @@ class Patcher: if self.should_get_inverses: statement += ", inverses JSON" statement += ");" - print(statement) self.c.execute(statement) - def create_mysql_table(self, ifc_class, declaration): + def create_mysql_table(self, ifc_class: str, declaration: ifcopenshell.ifcopenshell_wrapper.declaration) -> None: declaration = self.schema.declaration_by_name(ifc_class) statement = f"CREATE TABLE IF NOT EXISTS {ifc_class} (" statement += "`ifc_id` int(10) unsigned NOT NULL," @@ -401,11 +406,9 @@ class Patcher: statement += " PRIMARY KEY (`ifc_id`)" statement += ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;" - print(statement) self.c.execute(statement) - def insert_data(self, ifc_class): - print("Extracting data for", ifc_class) + def insert_data(self, ifc_class: str) -> None: elements = self.file.by_type(ifc_class, include_subtypes=False) rows = [] @@ -478,14 +481,14 @@ class Patcher: if pset_rows: self.c.executemany("INSERT INTO psets VALUES (%s, %s, %s, %s);", pset_rows) - def serialise_value(self, element, value): + def serialise_value(self, element: ifcopenshell.entity_instance, value: Any) -> Any: return element.walk( lambda v: isinstance(v, ifcopenshell.entity_instance), lambda v: v.id() if v.id() else {"type": v.is_a(), "value": v.wrappedValue}, value, ) - def get_permutations(self, lst, indexes): + def get_permutations(self, lst: list[Any], indexes: list[int]) -> list[Any]: nested_lists = [lst[i] for i in indexes] # Generate the Cartesian product of the nested lists @@ -501,7 +504,7 @@ class Patcher: return final_lists - def is_entity_list(self, attribute): + def is_entity_list(self, attribute: ifcopenshell.ifcopenshell_wrapper.attribute) -> bool: attribute = str(attribute.type_of_attribute()) if (attribute.startswith("", attribute): diff --git a/src/ifcpatch/ifcpatch/recipes/Migrate.py b/src/ifcpatch/ifcpatch/recipes/Migrate.py index 4b3a1cf8c9..9938ddd754 100644 --- a/src/ifcpatch/ifcpatch/recipes/Migrate.py +++ b/src/ifcpatch/ifcpatch/recipes/Migrate.py @@ -28,7 +28,7 @@ class Patcher: src: str, file: ifcopenshell.file, logger: Logger, - schema: typing.Literal["IFC2X3", "IFC4", "IFC4X3"] = "IFC4", + schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", ): """Migrate from one IFC version to another diff --git a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py index 0769e2027e..06929cea59 100644 --- a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py +++ b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py @@ -92,7 +92,7 @@ class Patcher: ) -> None: geometry = getattr(shape, "geometry", shape) v = [[x.tolist() for x in ifcopenshell.util.shape.get_vertices(geometry)]] - f = [ifcopenshell.util.shape.get_faces(geometry)] + f = [ifcopenshell.util.shape.get_faces(geometry).tolist()] replacements[element] = (v, f) iterator = ifcopenshell.geom.iterator(settings, self.file, multiprocessing.cpu_count(), include=products) @@ -112,6 +112,8 @@ class Patcher: # Do the replacements outside the iterator to prevent messing up iterator state. for element, geometry in replacements.items(): v, f = geometry + if not v or not f: + continue mesh = ifcopenshell.api.run( "geometry.add_mesh_representation", self.file, diff --git a/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py b/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py new file mode 100644 index 0000000000..7020a86504 --- /dev/null +++ b/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py @@ -0,0 +1,84 @@ +# IfcPatch - IFC patching utiliy +# Copyright (C) 2020, 2021, 2022 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.api.pset +import ifcopenshell.guid +import ifcopenshell.util.element +import ifcopenshell.util.selector +from logging import Logger + + +class Patcher: + def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, query: str = ""): + """Create independent copies for shared psets in IFC file. + + In IFC it's possible that same property set is shared by multiple elements, + so editing it's properties will automatically change their values for all those elements. + + Sometimes it's intended but sometimes it's not and it's just the way some other + software exports IFC (e.g. there is a known case when Tekla exports shared psets for all the occurrences). + While it is more optimized way to store data, it may lead to unexpected results when editing properties. + + This recipe creates independent copies of all shared psets (may be limited by the query) + and assigns them to the elements, so they can be edited without affecting any other elements. + + :param query: A query to select the subset of IFC elements, optional. + If not provided, patch will be applied to all shared property sets in the model. + + Example: + + .. code:: python + + # Unshare all psets in the IFC file. + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "UnsharePsets"}) + + # Unshare psets on all IfcWalls. + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "UnsharePsets", "arguments": ["IfcWall"]}) + """ + self.src = src + self.file = file + self.logger = logger + self.query = query + + def patch(self): + filtered_elements: set[ifcopenshell.entity_instance] = set() + if self.query: + filtered_elements = ifcopenshell.util.selector.filter_elements(self.file, self.query) + + all_psets = self.file.by_type("IfcPropertySetDefinition") + psets: dict[ifcopenshell.entity_instance, set[ifcopenshell.entity_instance]] = {} + for pset in all_psets: + elements = ifcopenshell.util.element.get_elements_by_pset(pset) + # Skip non shared psets. + if len(elements) < 2: + continue + # Skip non selected elements. + if self.query: + if not any(e in filtered_elements for e in elements): + continue + elements = elements.intersection(filtered_elements) + psets[pset] = elements + + new_psets = [] + for pset, elements in psets.items(): + # Let the first element to keep the original property set. + elements = list(elements) + new_psets.extend(ifcopenshell.api.pset.unshare_pset(self.file, elements, pset)) + + print(f"{len(new_psets)} new psets were created.") diff --git a/src/ifcpatch/test/test_Ifc2Sql.py b/src/ifcpatch/test/test_Ifc2Sql.py new file mode 100644 index 0000000000..529cdb3214 --- /dev/null +++ b/src/ifcpatch/test/test_Ifc2Sql.py @@ -0,0 +1,46 @@ +# 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 +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.georeference +import ifcopenshell.geom +import ifcopenshell.util.geolocation +import ifcopenshell.util.placement +import ifcopenshell.util.representation +import ifcopenshell.util.shape +import ifcopenshell.util.shape_builder +import test.bootstrap +from pathlib import Path + + +class TestIfc2Sql: + def test_run(self): + TEST_FILE = Path(__file__).parent / "files" / "basic.ifc" + sqlite_path = ifcpatch.execute( + {"file": ifcopenshell.open(TEST_FILE), "recipe": "Ifc2Sql", "arguments": ["sqlite"]} + ) + assert isinstance(sqlite_path, str) + assert sqlite_path.endswith(".ifcsqlite") + + # Ensure file is valid. + ifc_sqlite = ifcopenshell.open(sqlite_path) + assert isinstance(ifc_sqlite, ifcopenshell.sqlite) + assert ifc_sqlite.by_id(1) diff --git a/src/ifcpatch/test/test_TesselateElements.py b/src/ifcpatch/test/test_TesselateElements.py new file mode 100644 index 0000000000..83ea349137 --- /dev/null +++ b/src/ifcpatch/test/test_TesselateElements.py @@ -0,0 +1,84 @@ +# 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.util.representation +import ifcpatch +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.profile +import ifcopenshell.api.root +import ifcopenshell.api.unit +import test.bootstrap +import ifcopenshell.geom +import ifcopenshell.util.element +from ifcopenshell.util.shape_builder import ShapeBuilder + + +class TestTesselateElements(test.bootstrap.IFC4): + def test_run(self): + is_ifc2x3 = self.file.schema == "IFC2X3" + ifcopenshell.api.root.create_entity(self.file, "IfcProject") + wall = ifcopenshell.api.root.create_entity(self.file, "IfcWall") + model = ifcopenshell.api.context.add_context(self.file, "Model") + body = ifcopenshell.api.context.add_context( + self.file, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model, + ) + + # Add a length unit just for tests. + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) + + builder = ShapeBuilder(self.file) + rect = builder.polyline(builder.get_rectangle_coords(), closed=True) + extrusion = builder.extrude(rect, 1.0) + rep = builder.get_representation(body, extrusion) + ifcopenshell.api.geometry.assign_representation(self.file, wall, rep) + original_rep_id = rep.id() + + ifcpatch.execute({"file": self.file, "recipe": "TessellateElements", "arguments": ["IfcWall"]}) + + # Original representation still exists. + assert self.file.by_id(original_rep_id) + + other_reps = [r for r in ifcopenshell.util.representation.get_representations_iter(wall) if r != rep] + assert len(other_reps) == 1 + new_rep = other_reps[0] + new_rep.ContextOfItems = body + new_rep.RepresentationIdentifier = "Body" + new_rep.RepresentationType = "Tesselation" + assert len(items := new_rep.Items) == 1 + item = items[0] + if is_ifc2x3: + assert item.is_a("IfcFacetedBrep") + assert len(faces := item.Outer.CfsFaces) == 12 + for face in faces: + assert len(face.Bounds[0].Bound.Polygon) == 3 + else: + assert item.is_a("IfcPolygonalFaceSet") + assert len(faces := item.Faces) == 12 + for face in faces: + assert len(face.CoordIndex) == 3 + + +class TestTesselateElementsIFC2X3(test.bootstrap.IFC2X3, TestTesselateElements): + pass diff --git a/src/ifcpatch/test/test_UnsharePsets.py b/src/ifcpatch/test/test_UnsharePsets.py new file mode 100644 index 0000000000..d2226223bc --- /dev/null +++ b/src/ifcpatch/test/test_UnsharePsets.py @@ -0,0 +1,94 @@ +# 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 +import ifcopenshell +import ifcopenshell.api.pset +import ifcopenshell.geom +import ifcopenshell.util.element +import test.bootstrap + + +class TestUnsharePsets(test.bootstrap.IFC4): + def test_unshare_all_psets(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + + ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = elements + + ifcpatch.execute({"file": self.file, "recipe": "UnsharePsets"}) + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 3 + + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_by_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + + def test_unshare_all_psets_include_types(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + elements[1].HasPropertySets = (pset,) + elements[2].HasPropertySets = (pset,) + + ifcpatch.execute({"file": self.file, "recipe": "UnsharePsets"}) + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + + used_psets: set[ifcopenshell.entity_instance] = set() + for element in elements: + element_psets = element.HasPropertySets + assert len(element_psets) == 1 + used_psets.add(element_psets[0]) + + assert used_psets == set(psets) + + def test_unshare_psets_for_elements_from_query(self): + shared_pset_elements = [self.file.create_entity("IfcSlab") for _ in range(3)] + shared_pset = ifcopenshell.api.pset.add_pset(self.file, shared_pset_elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = shared_pset_elements + + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[1] + rel.RelatedObjects = elements + + ifcpatch.execute({"file": self.file, "recipe": "UnsharePsets", "arguments": ["IfcWall"]}) + assert len(psets := self.file.by_type("IfcPropertySet")) == 4 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 4 + + psets.remove(shared_pset) + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_by_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + # Leave shared pset untouched as it's not part of the provided query. + assert ifcopenshell.util.element.get_elements_by_pset(shared_pset) == set(shared_pset_elements) + + +class TestUnsharePsetsIFC2X3(test.bootstrap.IFC2X3, TestUnsharePsets): + pass diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index c5e9443916..e268b87d47 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -149,8 +149,8 @@ class Facet: return "This facet cannot be interpreted" def to_ids_value(self, parameter: Union[str, Restriction, list]) -> dict[str, Any]: - if isinstance(parameter, str): - parameter_dict = {"simpleValue": parameter} + if isinstance(parameter, (int, float, str)): + parameter_dict = {"simpleValue": str(parameter)} elif isinstance(parameter, Restriction): parameter_dict = {"xs:restriction": [parameter.asdict()]} elif isinstance(parameter, list): diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt index 870641f9b6..337056abf6 100644 --- a/src/ifcwrap/CMakeLists.txt +++ b/src/ifcwrap/CMakeLists.txt @@ -57,7 +57,18 @@ if (WITH_CGAL) endif() SET_SOURCE_FILES_PROPERTIES(IfcPython.i PROPERTIES CPLUSPLUS ON) +# Rebuild on changes in other .i files. +SET_PROPERTY( + SOURCE IfcPython.i + PROPERTY DEPENDS + IfcGeomWrapper.i + IfcParseWrapper.i + utils/type_conversion.i + utils/typemaps_in.i + utils/typemaps_out.i +) swig_add_library(ifcopenshell_wrapper LANGUAGE python SOURCES IfcPython.i) +SET_PROPERTY(TARGET ${SWIG_MODULE_ifcopenshell_wrapper_REAL_NAME} PROPERTY SWIG_DEPENDS ${IFCOPENSHELL_LIBRARIES}) if("$ENV{LDFLAGS}" MATCHES ".undefined.suppress") # On osx there is some state in the python dylib. With `-Wl,undefined,suppress` we can ignore the missing symbols at compile time. SWIG_LINK_LIBRARIES(ifcopenshell_wrapper ${IFCOPENSHELL_LIBRARIES} ${OPENCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${LIBSVGFILL}) @@ -111,10 +122,10 @@ IF((PYTHONINTERP_FOUND AND NOT "${PYTHON_EXECUTABLE}" STREQUAL "") OR PYTHON_MOD ENDFOREACH() INSTALL(FILES "${CMAKE_BINARY_DIR}/ifcwrap/ifcopenshell_wrapper.py" DESTINATION "${python_package_dir}/ifcopenshell") - INSTALL(TARGETS _ifcopenshell_wrapper + INSTALL(TARGETS ${SWIG_MODULE_ifcopenshell_wrapper_REAL_NAME} DESTINATION "${python_package_dir}/ifcopenshell") if (MSVC) - install(FILES $ DESTINATION bin OPTIONAL) + INSTALL(FILES $ DESTINATION bin OPTIONAL) endif() ENDIF() ELSE() diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index e9bcd72985..9b1ab46395 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -117,6 +117,7 @@ std::pair vector_to_buffer(const T& t) { // @this is really annoying, but apparently inheritance // is lost in swig in the shared_ptr type hiearchy using namespace ifcopenshell::geometry::taxonomy; + if (!$1) $1 = try_upcast($input, SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__boolean_result_t); if (!$1) $1 = try_upcast($input, SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__bspline_curve_t); if (!$1) $1 = try_upcast($input, SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__bspline_surface_t); if (!$1) $1 = try_upcast($input, SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__circle_t); @@ -150,10 +151,52 @@ std::pair vector_to_buffer(const T& t) { std::string taxonomy_item_repr(ifcopenshell::geometry::taxonomy::item::ptr i) { std::ostringstream oss; i->print(oss); - return oss.str(); + std::string result = oss.str(); + + // Strip new line at the end of the printed result. + // result is probably always ends with \n but just to be safe. + if (!result.empty() && result.back() == '\n') { + result.pop_back(); + } + return result; } %} +%{ + + +namespace { + // Helper function to create a Python tuple from an Eigen matrix/vector + template + PyObject* eigen_to_python_tuple(const Eigen::MatrixBase& mat) { + constexpr auto rows = T::RowsAtCompileTime; + constexpr auto cols = T::ColsAtCompileTime; + + if constexpr (rows == 1 || cols == 1) { + // Eigen::Vector (1D array) + PyObject* tuple = PyTuple_New(rows * cols); + for (int i = 0; i < mat.size(); ++i) { + PyTuple_SetItem(tuple, i, PyFloat_FromDouble(mat(i))); + } + return tuple; + } else { + // Eigen::Matrix (2D array) + PyObject* tuple = PyTuple_New(rows); + for (int i = 0; i < rows; ++i) { + PyObject* row = PyTuple_New(cols); + for (int j = 0; j < cols; ++j) { + PyTuple_SetItem(row, j, PyFloat_FromDouble(mat(i, j))); + } + PyTuple_SetItem(tuple, i, row); + } + return tuple; + } + } +} + +%} + +%shared_ptr(ifcopenshell::geometry::taxonomy::boolean_result); %shared_ptr(ifcopenshell::geometry::taxonomy::item); %shared_ptr(ifcopenshell::geometry::taxonomy::implicit_item); %shared_ptr(ifcopenshell::geometry::taxonomy::piecewise_function); @@ -203,6 +246,7 @@ std::string taxonomy_item_repr(ifcopenshell::geometry::taxonomy::item::ptr i) { %include "../ifcgeom/Iterator.h" %include "../ifcgeom/GeometrySerializer.h" %include "../ifcgeom/taxonomy.h" +%include "../ifcgeom/piecewise_function_evaluator.h" %include "../serializers/SvgSerializer.h" %include "../serializers/HdfSerializer.h" @@ -223,46 +267,85 @@ std::string taxonomy_item_repr(ifcopenshell::geometry::taxonomy::item::ptr i) { } } -%extend ifcopenshell::geometry::taxonomy::point3 { - PyObject* coords_() const { - auto result = PyTuple_New(3); - for (int i = 0; i < 3; ++i) { - PyTuple_SET_ITEM(result, i, PyFloat_FromDouble($self->ccomponents().data()[i])); - } - return result; + +%define assign_component_acccess(item_name) + +%extend ifcopenshell::geometry::taxonomy::item_name { + PyObject* components_() const { + return eigen_to_python_tuple(self->ccomponents()); } %pythoncode %{ - coords = property(coords_) + components = property(components_) %} -} +}; -%extend ifcopenshell::geometry::taxonomy::direction3 { - // @todo refactor in macro - PyObject* coords_() const { - auto result = PyTuple_New(3); - for (int i = 0; i < 3; ++i) { - PyTuple_SET_ITEM(result, i, PyFloat_FromDouble($self->ccomponents().data()[i])); - } - return result; - } +%enddef - %pythoncode %{ - coords = property(coords_) - %} -} +assign_component_acccess(point3); +assign_component_acccess(direction3); +assign_component_acccess(matrix4); +assign_component_acccess(colour); +%define assign_children_access(item_name, children_type) -%extend ifcopenshell::geometry::taxonomy::loop { - const std::vector& children_() const { +%extend ifcopenshell::geometry::taxonomy::item_name { + // swig does not accept auto here as the return type + const std::vector& children_() const { return $self->children; } + const ifcopenshell::geometry::taxonomy::children_type::ptr& __getitem__(int index) const { + if (index < 0 || index >= $self->children.size()) { + throw std::runtime_error("Index " + std::to_string(index) + " is out of bounds for an array of length " + std::to_string($self->children.size())); + } + return $self->children[index]; + } + %pythoncode %{ children = property(children_) + def __iter__(self): + return iter(self.children) %} -} +}; +%enddef + +assign_children_access(collection, geom_item); +assign_children_access(loop, edge); +assign_children_access(face, loop); +assign_children_access(shell, face); +assign_children_access(solid, shell); +assign_children_access(loft, geom_item); +assign_children_access(boolean_result, geom_item); + +%define assign_matrix_access(item_name) + +%extend ifcopenshell::geometry::taxonomy::item_name { + // swig does not accept auto here as the return type + const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix_() const { + return $self->matrix; + } + + %pythoncode %{ + matrix = property(matrix_) + %} +}; + +%enddef + +assign_matrix_access(line); +assign_matrix_access(circle); +assign_matrix_access(ellipse); +assign_matrix_access(collection); +assign_matrix_access(solid); +assign_matrix_access(face); +assign_matrix_access(plane); +assign_matrix_access(cylinder); +assign_matrix_access(sphere); +assign_matrix_access(torus); +assign_matrix_access(extrusion); +assign_matrix_access(revolve); %extend ifcopenshell::geometry::Settings { void set_(const std::string& name, bool val) { @@ -292,6 +375,9 @@ std::string taxonomy_item_repr(ifcopenshell::geometry::taxonomy::item::ptr i) { void set_(const std::string& name, const std::set& val) { return $self->set(name, val); } + void set_(const std::string& name, const std::vector& val) { + return $self->set(name, val); + } ifcopenshell::geometry::Settings::value_variant_t get_(const std::string& name) { return $self->get(name); } @@ -561,6 +647,10 @@ struct ShapeRTTI : public boost::static_visitor return vector_to_buffer(self->item_ids()); } + std::pair edges_item_ids_buffer() const { + return vector_to_buffer(self->edges_item_ids()); + } + std::pair verts_buffer() const { return vector_to_buffer(self->verts()); } @@ -595,8 +685,17 @@ struct ShapeRTTI : public boost::static_visitor %pythoncode %{ # Hide the getters with read-only property implementations - id = property(id) - faces = property(faces) + faces_tri = property(faces) + polyhedral_faces_without_holes = property(polyhedral_faces_without_holes) + polyhedral_faces_with_holes = property(polyhedral_faces_with_holes) + def get_faces(self): + if self.faces_tri: + return self.faces_tri + elif self.polyhedral_faces_without_holes: + return self.polyhedral_faces_without_holes + else: + return self.polyhedral_faces_with_holes + faces = property(get_faces) edges = property(edges) material_ids = property(material_ids) materials = property(materials) @@ -608,16 +707,23 @@ struct ShapeRTTI : public boost::static_visitor edges_buffer = property(edges_buffer) material_ids_buffer = property(material_ids_buffer) item_ids_buffer = property(item_ids_buffer) + edges_item_ids_buffer = property(edges_item_ids_buffer) verts_buffer = property(verts_buffer) normals_buffer = property(normals_buffer) colors_buffer = property(colors_buffer) %} }; -%extend IfcGeom::Representation::Serialization { +%extend IfcGeom::Representation::Representation { %pythoncode %{ # Hide the getters with read-only property implementations id = property(id) + %} +}; + +%extend IfcGeom::Representation::Serialization { + %pythoncode %{ + # Hide the getters with read-only property implementations brep_data = property(brep_data) surface_styles = property(surface_styles) surface_style_ids = property(surface_style_ids) @@ -821,7 +927,10 @@ struct ShapeRTTI : public boost::static_visitor IfcGeom::BRepElement* brep = kernel.create_brep_for_representation_and_product(ifc_representation, product); if (!brep) { - throw IfcParse::IfcException("Failed to process shape"); + std::ostringstream oss_repr, oss_product; + ifc_representation->toString(oss_repr); + product->toString(oss_product); + throw IfcParse::IfcException("Failed to process shape. Product: " + oss_product.str() + ", representation: " + oss_repr.str()); } if (settings.get().get() == ifcopenshell::geometry::settings::SERIALIZED) { IfcGeom::SerializedElement* serialization = new IfcGeom::SerializedElement(*brep); @@ -851,7 +960,9 @@ struct ShapeRTTI : public boost::static_visitor try { shapes = kernel.convert(instance); } catch (...) { - throw IfcParse::IfcException("Failed to process shape"); + std::ostringstream oss; + instance->toString(oss); + throw IfcParse::IfcException("Failed to process shape. Instance: " + oss.str()); } IfcGeom::Representation::BRep brep(settings, instance->declaration().name(), to_locale_invariant_string(instance->as()->id()), shapes); @@ -1131,6 +1242,7 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type %enddef +assign_repr(ifcopenshell::geometry::taxonomy::boolean_result) assign_repr(ifcopenshell::geometry::taxonomy::bspline_curve) assign_repr(ifcopenshell::geometry::taxonomy::bspline_surface) assign_repr(ifcopenshell::geometry::taxonomy::circle) @@ -1159,4 +1271,5 @@ assign_repr(ifcopenshell::geometry::taxonomy::torus) assign_repr(ifcopenshell::geometry::taxonomy::style) assign_repr(ifcopenshell::geometry::taxonomy::sweep_along_curve) + #endif diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index cff9922693..e0e109b97a 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -56,24 +56,6 @@ private: %rename("add") addEntity; %rename("remove") removeEntity; -%{ - -template -struct is_std_vector : std::false_type {}; -template -struct is_std_vector> : std::true_type {}; -template -constexpr bool is_std_vector_v = is_std_vector::value; - -template -struct is_std_vector_vector : std::false_type {}; -template -struct is_std_vector_vector, Alloc2>> : std::true_type {}; -template -constexpr bool is_std_vector_vector_v = is_std_vector_vector::value; - -%} - class attribute_value_derived {}; %{ class attribute_value_derived {}; @@ -520,6 +502,26 @@ static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClas } } +// Expose FileDescription and FileName header entities +// to make them readable even if they were not filled properly before. +// Though it is invalid IFC, technically. +// FileSchema is not exposed as IFC file won't load if it's invalid. + +%extend IfcParse::FileDescription { + AttributeValue description() const { return $self->getArgument(0); } + AttributeValue implementation_level() const { return $self->getArgument(1); } +}; + +%extend IfcParse::FileName { + AttributeValue name() const { return $self->getArgument(0); } + AttributeValue time_stamp() const { return $self->getArgument(1); } + AttributeValue author() const { return $self->getArgument(2); } + AttributeValue organization() const { return $self->getArgument(3); } + AttributeValue preprocessor_version() const { return $self->getArgument(4); } + AttributeValue originating_system() const { return $self->getArgument(5); } + AttributeValue authorization() const { return $self->getArgument(6); } +}; + %extend IfcParse::IfcSpfHeader { %pythoncode %{ # Hide the getters with read-only property implementations @@ -760,9 +762,7 @@ static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClas PyObject* convert_cpp_attribute_to_python(AttributeValue arg) { return arg.array_->apply_visitor([](auto& v){ using U = std::decay_t; - if constexpr (is_std_vector_vector_v) { - return pythonize_vector2(v); - } else if constexpr (is_std_vector_v) { + if constexpr (is_std_vector_v) { return pythonize_vector(v); } else if constexpr (std::is_same_v) { return pythonize(std::string(v.value())); @@ -773,6 +773,25 @@ static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClas Py_INCREF(Py_None); return static_cast(Py_None); } + } else if constexpr (std::is_same_v) { + return get_info_cpp(v); + } else if constexpr (std::is_same_v) { + auto r = PyTuple_New(v->size()); + for (unsigned i = 0; i < v->size(); ++i) { + PyTuple_SetItem(r, i, get_info_cpp((*v)[i])); + } + return r; + } else if constexpr (std::is_same_v) { + auto rs = PyTuple_New(v->size()); + for (auto it = v->begin(); it != v->end(); ++it) { + auto v_i = it; + auto r = PyTuple_New(v_i->size()); + for (unsigned i = 0; i < v_i->size(); ++i) { + PyTuple_SetItem(r, i, get_info_cpp((*v_i)[i])); + } + PyTuple_SetItem(rs, std::distance(v->begin(), it), r); + } + return rs; } else if constexpr (std::is_same_v || std::is_same_v || std::is_same_v) { Py_INCREF(Py_None); return static_cast(Py_None); diff --git a/src/ifcwrap/IfcPython.i b/src/ifcwrap/IfcPython.i index b3b6cc3a85..72fe60f21d 100644 --- a/src/ifcwrap/IfcPython.i +++ b/src/ifcwrap/IfcPython.i @@ -84,6 +84,7 @@ %{ #include "../ifcgeom/Iterator.h" #include "../ifcgeom/taxonomy.h" + #include "../ifcgeom/piecewise_function_evaluator.h" #ifdef IFOPSH_WITH_OPENCASCADE #include "../ifcgeom/Serialization/Serialization.h" #include "../ifcgeom/kernels/opencascade/IfcGeomTree.h" @@ -147,6 +148,24 @@ #endif %} +%{ + +template +struct is_std_vector : std::false_type {}; +template +struct is_std_vector> : std::true_type {}; +template +constexpr bool is_std_vector_v = is_std_vector::value; + +template +struct is_std_vector_vector : std::false_type {}; +template +struct is_std_vector_vector, Alloc2>> : std::true_type {}; +template +constexpr bool is_std_vector_vector_v = is_std_vector_vector::value; + +%} + // Create docstrings for generated python code. %feature("autodoc", "1"); @@ -159,6 +178,7 @@ %module ifcopenshell_wrapper %{ #include "../ifcgeom/Converter.h" #include "../ifcgeom/taxonomy.h" + #include "../ifcgeom/piecewise_function_evaluator.h" #ifdef IFOPSH_WITH_OPENCASCADE #include "../ifcgeom/Serialization/Serialization.h" #include "../ifcgeom/kernels/opencascade/IfcGeomTree.h" diff --git a/src/ifcwrap/utils/type_conversion.i b/src/ifcwrap/utils/type_conversion.i index 5ef2ceb124..6c56cc631b 100644 --- a/src/ifcwrap/utils/type_conversion.i +++ b/src/ifcwrap/utils/type_conversion.i @@ -189,21 +189,15 @@ } template - PyObject* pythonize_vector(const std::vector& v) { + PyObject* pythonize_vector(const T& v) { const size_t size = v.size(); PyObject* pyobj = PyTuple_New(size); for (size_t i = 0; i < size; ++i) { - PyTuple_SetItem(pyobj, i, pythonize(v[i])); - } - return pyobj; - } - - template - PyObject* pythonize_vector2(const std::vector< std::vector >& v) { - const size_t size = v.size(); - PyObject* pyobj = PyTuple_New(size); - for (size_t i = 0; i < size; ++i) { - PyTuple_SetItem(pyobj, i, pythonize_vector(v[i])); + if constexpr (is_std_vector_v) { + PyTuple_SetItem(pyobj, i, pythonize_vector(v[i])); + } else { + PyTuple_SetItem(pyobj, i, pythonize(v[i])); + } } return pyobj; } @@ -228,6 +222,8 @@ } else if constexpr (std::is_same_v>, std::set>) { std::vector vs(t.begin(), t.end()); return pythonize_vector(vs); + } else if constexpr (std::is_same_v>, std::vector>) { + return pythonize_vector(t); } else { return pythonize(t); } @@ -247,7 +243,8 @@ PyObject* item_to_pyobject(const ifcopenshell::geometry::taxonomy::item::ptr& i) auto kind = i->kind(); // @todo this is not automatically generated :( // we can probably use the dispatch mechanism for this we already have in the kernel - if (kind == BSPLINE_CURVE) { return SWIG_NewPointerObj(SWIG_as_voidptr(new std::shared_ptr(std::static_pointer_cast(i))), SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__bspline_curve_t, 0 | SWIG_POINTER_OWN); } + if (kind == BOOLEAN_RESULT) { return SWIG_NewPointerObj(SWIG_as_voidptr(new std::shared_ptr(std::static_pointer_cast(i))), SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__boolean_result_t, 0 | SWIG_POINTER_OWN); } + else if (kind == BSPLINE_CURVE) { return SWIG_NewPointerObj(SWIG_as_voidptr(new std::shared_ptr(std::static_pointer_cast(i))), SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__bspline_curve_t, 0 | SWIG_POINTER_OWN); } else if (kind == BSPLINE_SURFACE) { return SWIG_NewPointerObj(SWIG_as_voidptr(new std::shared_ptr(std::static_pointer_cast(i))), SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__bspline_surface_t, 0 | SWIG_POINTER_OWN); } else if (kind == CIRCLE) { return SWIG_NewPointerObj(SWIG_as_voidptr(new std::shared_ptr(std::static_pointer_cast(i))), SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__circle_t, 0 | SWIG_POINTER_OWN); } else if (kind == COLLECTION) { return SWIG_NewPointerObj(SWIG_as_voidptr(new std::shared_ptr(std::static_pointer_cast(i))), SWIGTYPE_p_std__shared_ptrT_ifcopenshell__geometry__taxonomy__collection_t, 0 | SWIG_POINTER_OWN); } diff --git a/src/ifcwrap/utils/typemaps_out.i b/src/ifcwrap/utils/typemaps_out.i index ea5648030e..1c9761e4fe 100644 --- a/src/ifcwrap/utils/typemaps_out.i +++ b/src/ifcwrap/utils/typemaps_out.i @@ -38,9 +38,7 @@ try { $result = $1.array_->apply_visitor([](auto& v){ using U = std::decay_t; - if constexpr (is_std_vector_vector_v) { - return pythonize_vector2(v); - } else if constexpr (is_std_vector_v) { + if constexpr (is_std_vector_v) { return pythonize_vector(v); } else if constexpr (std::is_same_v) { return pythonize(std::string(v.value())); @@ -70,10 +68,22 @@ %define CREATE_VECTOR_TYPEMAP_OUT(template_type) %typemap(out) std::vector { - $result = pythonize_vector($1); + $result = pythonize_vector>($1); } %typemap(out) const std::vector& { - $result = pythonize_vector(*$1); + $result = pythonize_vector>(*$1); + } + %typemap(out) std::vector> { + $result = pythonize_vector>>($1); + } + %typemap(out) const std::vector>& { + $result = pythonize_vector>>(*$1); + } + %typemap(out) std::vector>> { + $result = pythonize_vector>>>($1); + } + %typemap(out) const std::vector>>& { + $result = pythonize_vector>>>(*$1); } %enddef @@ -112,8 +122,14 @@ CREATE_VECTOR_TYPEMAP_OUT(IfcGeom::ConversionResultShape *) } }; +%typemap(out) const item_name::ptr& { + $result = item_to_pyobject(*$1); +}; + %enddef +vector_of_item(ifcopenshell::geometry::taxonomy::item) +vector_of_item(ifcopenshell::geometry::taxonomy::boolean_result) vector_of_item(ifcopenshell::geometry::taxonomy::bspline_curve) vector_of_item(ifcopenshell::geometry::taxonomy::bspline_surface) vector_of_item(ifcopenshell::geometry::taxonomy::circle) @@ -141,3 +157,4 @@ vector_of_item(ifcopenshell::geometry::taxonomy::sphere) vector_of_item(ifcopenshell::geometry::taxonomy::torus) vector_of_item(ifcopenshell::geometry::taxonomy::style) vector_of_item(ifcopenshell::geometry::taxonomy::sweep_along_curve) +vector_of_item(ifcopenshell::geometry::taxonomy::geom_item) diff --git a/src/opencdeserver/api/app/ifcgraph/ifcgraph.py b/src/opencdeserver/api/app/ifcgraph/ifcgraph.py index 74f27babd7..afe1c36749 100644 --- a/src/opencdeserver/api/app/ifcgraph/ifcgraph.py +++ b/src/opencdeserver/api/app/ifcgraph/ifcgraph.py @@ -21,6 +21,7 @@ from py2neo.data import Node, Relationship from uuid import uuid4 import ifcopenshell +import ifcopenshell.util.schema from py2neo import Graph @@ -33,9 +34,10 @@ def create_pure_node_from_ifc_entity(ifc_entity, ifc_file, hierarchy=True): node["id"] = str(uuid4()) node["name"] = ifc_entity.is_a() if hierarchy: - for label in ifc_file.wrapped_data.types_with_super(): - if ifc_entity.is_a(label): - node.add_label(label) + node.add_label(ifc_entity.is_a()) + declaration = ifcopenshell.util.schema.get_declaration(ifc_entity) + for supertype in ifcopenshell.util.schema.get_supertypes(declaration): + node.add_label(supertype.name()) else: node.add_label(ifc_entity.is_a()) attributes_type = ["ENTITY INSTANCE", "AGGREGATE OF ENTITY INSTANCE", "DERIVED"] diff --git a/src/qtviewer/IfcViewerWidget.cpp b/src/qtviewer/IfcViewerWidget.cpp index ff9109ba62..0e8fcb3e21 100644 --- a/src/qtviewer/IfcViewerWidget.cpp +++ b/src/qtviewer/IfcViewerWidget.cpp @@ -1,5 +1,4 @@ #include "IfcViewerWidget.h" -#include #include #include #include @@ -186,14 +185,18 @@ void IfcViewerWidget::orientScene(osg::ref_ptr rootGroup) // In case a cameraManipulator is set, lookAt position setting does not work on the camera // as it is overridden. // So set it on the manipulator and reapply it to the viewer - m_mouseHandler->setHomePosition(eye, center, up); + if (m_mouseHandler.valid()) + { + m_mouseHandler->setHomePosition(eye, center, up); + } + m_viewer->setCameraManipulator(m_mouseHandler); - auto log = [center, radius, eye]() { - MessageLogger::log("center: " + std::to_string(center.length())); - MessageLogger::log("radius: " + std::to_string(radius)); - MessageLogger::log("center: " + std::to_string(center.x()) + ", " + std::to_string(center.y()) + ", " + std::to_string(center.z())); - MessageLogger::log("eye: " + std::to_string(eye.x()) + ", " + std::to_string(eye.y()) + ", " + std::to_string(eye.z())); - }; - //log(); + auto log = [center, radius, eye]() { + MessageLogger::log("center: " + std::to_string(center.length())); + MessageLogger::log("radius: " + std::to_string(radius)); + MessageLogger::log("center: " + std::to_string(center.x()) + ", " + std::to_string(center.y()) + ", " + std::to_string(center.z())); + MessageLogger::log("eye: " + std::to_string(eye.x()) + ", " + std::to_string(eye.y()) + ", " + std::to_string(eye.z())); + }; + //log(); } diff --git a/src/qtviewer/ParseIfcFile.cpp b/src/qtviewer/ParseIfcFile.cpp index 8e1967b42c..0936d5624b 100644 --- a/src/qtviewer/ParseIfcFile.cpp +++ b/src/qtviewer/ParseIfcFile.cpp @@ -1,21 +1,20 @@ #include "ParseIfcFile.h" -#include "MessageLogger.h" +#include "MessageLogger.h" #include "osg/Array" #include "osg/Geometry" #include "osg/Material" -#include "osg/MatrixTransform" #include "osg/Matrixd" +#include "osg/MatrixTransform" #include "osg/PrimitiveSet" +#include "osg/ref_ptr" #include "osg/StateAttribute" #include "osg/StateSet" #include "osg/Vec4" -#include "osg/ref_ptr" -#include -#include // size_t -#include +#include // size_t #include +#include #include ParseIfcFile::ParseIfcFile() {} @@ -27,11 +26,10 @@ bool ParseIfcFile::Parse( std::vector>& matrixTransforms ) { IfcParse::IfcFile file(filePath); - - IfcGeom::IteratorSettings settings; - settings.set(IfcGeom::IteratorSettings::USE_WORLD_COORDS, false); - settings.set(IfcGeom::IteratorSettings::WELD_VERTICES, false); - settings.set(IfcGeom::IteratorSettings::APPLY_DEFAULT_MATERIALS, true); + ifcopenshell::geometry::Settings settings; + settings.set("use-world-coords", false); + settings.set("weld-vertices", false); + settings.set("apply-default-materials", true); IfcGeom::Iterator* it = new IfcGeom::Iterator(settings, &file); if (!it->initialize()) { @@ -57,7 +55,16 @@ bool ParseIfcFile::Parse( //MessageLogger::log("id: " + id); // Transformation Matrix - const std::vector& matrixData = triElem->transformation().matrix().data(); + std::vector matrixData; + auto transform4x4 = triElem->transformation().data()->components(); + for (int col = 0; col<4; col++) + { + for (int row =0; row<3; row++) + { + matrixData.push_back(transform4x4(row,col)); + } + + } // Debugging: std::string matrixStr = ""; @@ -97,7 +104,7 @@ bool ParseIfcFile::Parse( const std::vector& elemFaces = triElemGeom->faces(); const std::vector& elemVertices = triElemGeom->verts(); const std::vector& elemNormals = triElemGeom->normals(); - const std::vector& elemMats = triElemGeom->materials(); + const std::vector& elemMats = triElemGeom->materials(); const std::vector& elemMatIds = triElemGeom->material_ids(); std::map> uniqueMaterials; @@ -190,15 +197,14 @@ void ParseIfcFile::buildTriangleNodes( addVertexAndNormal(index + 2); } -osg::ref_ptr ParseIfcFile::getOsgMaterial(const IfcGeom::Material& material) +osg::ref_ptr ParseIfcFile::getOsgMaterial(const ifcopenshell::geometry::taxonomy::style::ptr& material) { - const double* diffuseColor = material.diffuse(); + ifcopenshell::geometry::taxonomy::colour diffuseColor = material->diffuse; osg::Vec4 matOsgDiffuseColor( - diffuseColor[0], - diffuseColor[1], - diffuseColor[2], - material.transparency() - ); + diffuseColor.r(), + diffuseColor.g(), + diffuseColor.b(), + material->transparency); osg::ref_ptr osgMaterial = new osg::Material; osgMaterial->setDiffuse(osg::Material::FRONT_AND_BACK, matOsgDiffuseColor); diff --git a/src/qtviewer/ParseIfcFile.h b/src/qtviewer/ParseIfcFile.h index 548b4ec487..f3370f3c20 100644 --- a/src/qtviewer/ParseIfcFile.h +++ b/src/qtviewer/ParseIfcFile.h @@ -1,38 +1,37 @@ #ifndef PARSEIFCFILE_H #define PARSEIFCFILE_H +#include "../ifcgeom/Iterator.h" +#include "osg/Geometry" +#include "osg/Material" +#include "osg/MatrixTransform" +#include "osg/ref_ptr" + #include #include #include #include -#include "../ifcgeom_schema_agnostic/IfcGeomIterator.h" -#include "osg/Material" -#include "osg/MatrixTransform" -#include "osg/ref_ptr" -#include "osg/Geometry" -class ParseIfcFile : public QObject -{ +class ParseIfcFile : public QObject { -public: + public: ParseIfcFile(); ~ParseIfcFile(); bool Parse( const std::string& filePath, - std::vector>& matrixTransforms - ); + std::vector>& matrixTransforms); -private: + private: void buildTriangleNodes( const std::vector& fVertIds, - const std::vector& elemVertices, + const std::vector& elemVertices, const std::vector& elemNormals, size_t index, osg::ref_ptr osgVertices, osg::ref_ptr osgNormals); - osg::ref_ptr getOsgMaterial(const IfcGeom::Material& material); + osg::ref_ptr getOsgMaterial(const ifcopenshell::geometry::taxonomy::style::ptr& material); }; #endif // PARSEIFCFILE_H diff --git a/src/serializers/ColladaSerializer.cpp b/src/serializers/ColladaSerializer.cpp index 364685a426..0b1676c338 100644 --- a/src/serializers/ColladaSerializer.cpp +++ b/src/serializers/ColladaSerializer.cpp @@ -205,7 +205,6 @@ void ColladaSerializer::ColladaExporter::ColladaScene::add( const auto& posmatrix = transformation_towrite; - // @todo check double matrix_array[4][4] = { { (double)posmatrix(0,0), (double)posmatrix(0,1), (double)posmatrix(0,2), (double)posmatrix(0,3) }, { (double)posmatrix(1,0), (double)posmatrix(1,1), (double)posmatrix(1,2), (double)posmatrix(1,3) }, @@ -249,10 +248,10 @@ void ColladaSerializer::ColladaExporter::ColladaScene::addParent(const IfcGeom:: const auto& posmatrix = transformation_towrite; double matrix_array[4][4] = { - { (double)posmatrix(0,0), (double)posmatrix(1,0), (double)posmatrix(2,0), (double)posmatrix(3,0) }, - { (double)posmatrix(0,1), (double)posmatrix(1,1), (double)posmatrix(2,1), (double)posmatrix(3,1) }, - { (double)posmatrix(0,2), (double)posmatrix(1,2), (double)posmatrix(2,2), (double)posmatrix(3,2) }, - { (double)posmatrix(0,3), (double)posmatrix(1,3), (double)posmatrix(2,3), (double)posmatrix(3,3) } + { (double)posmatrix(0,0), (double)posmatrix(0,1), (double)posmatrix(0,2), (double)posmatrix(0,3) }, + { (double)posmatrix(1,0), (double)posmatrix(1,1), (double)posmatrix(1,2), (double)posmatrix(1,3) }, + { (double)posmatrix(2,0), (double)posmatrix(2,1), (double)posmatrix(2,2), (double)posmatrix(2,3) }, + { (double)posmatrix(3,0), (double)posmatrix(3,1), (double)posmatrix(3,2), (double)posmatrix(3,3) } }; std::string name = serializer->object_id(&parent); diff --git a/src/serializers/HdfSerializer.cpp b/src/serializers/HdfSerializer.cpp index a6388632bb..50b9be6e62 100644 --- a/src/serializers/HdfSerializer.cpp +++ b/src/serializers/HdfSerializer.cpp @@ -398,6 +398,7 @@ IfcGeom::Element* HdfSerializer::read(IfcParse::IfcFile& f, const std::string& g auto uvcoords = read_dataset(meshGroup, DATASET_NAME_UVCOORDS); auto material_ids = read_dataset(meshGroup, DATASET_NAME_MATERIAL_IDS); auto item_ids = read_dataset(meshGroup, DATASET_NAME_ITEM_IDS); + auto edges_item_ids = read_dataset(meshGroup, DATASET_NAME_EDGES_ITEM_IDS); std::vector surface_styles; @@ -435,6 +436,7 @@ IfcGeom::Element* HdfSerializer::read(IfcParse::IfcFile& f, const std::string& g material_ids, surface_style_ptrs, item_ids + , edges_item_ids )); triangulation_cache_.insert({ representation_id_str, triangulation_geometry }); @@ -696,6 +698,7 @@ const H5std_string HdfSerializer::DATASET_NAME_INDICES = "indices"; const H5std_string HdfSerializer::DATASET_NAME_EDGES = "edges"; const H5std_string HdfSerializer::DATASET_NAME_MATERIAL_IDS = "material_ids"; const H5std_string HdfSerializer::DATASET_NAME_ITEM_IDS = "item_ids"; +const H5std_string HdfSerializer::DATASET_NAME_EDGES_ITEM_IDS = "edges_item_ids"; const H5std_string HdfSerializer::DATASET_NAME_MATERIALS = "materials"; const H5std_string HdfSerializer::DATASET_NAME_OCCT = "brep"; const H5std_string HdfSerializer::DATASET_NAME_PLACEMENT = "placement"; diff --git a/src/serializers/HdfSerializer.h b/src/serializers/HdfSerializer.h index 0326300420..d5846337f6 100644 --- a/src/serializers/HdfSerializer.h +++ b/src/serializers/HdfSerializer.h @@ -47,6 +47,7 @@ private: static const H5std_string DATASET_NAME_EDGES; static const H5std_string DATASET_NAME_MATERIAL_IDS; static const H5std_string DATASET_NAME_ITEM_IDS; + static const H5std_string DATASET_NAME_EDGES_ITEM_IDS; static const H5std_string DATASET_NAME_MATERIALS; static const H5std_string DATASET_NAME_OCCT; static const H5std_string DATASET_NAME_PLACEMENT; diff --git a/src/serializers/schema_dependent/XmlSerializer.cpp b/src/serializers/schema_dependent/XmlSerializer.cpp index 489ffde09e..0dbd873fe3 100644 --- a/src/serializers/schema_dependent/XmlSerializer.cpp +++ b/src/serializers/schema_dependent/XmlSerializer.cpp @@ -352,6 +352,13 @@ ptree* descend(ifcopenshell::geometry::abstract_mapping* mapping, IfcSchema::Ifc } } +#ifdef SCHEMA_HAS_IfcAlignmentSegment + if (auto* als = product->as()) { + ptree node; + format_entity_instance(mapping, als->DesignParameters(), node, child, false); + } +#endif + return &child; } diff --git a/win/build-deps.cmd b/win/build-deps.cmd index 1a669eda49..27285121b6 100644 --- a/win/build-deps.cmd +++ b/win/build-deps.cmd @@ -114,7 +114,7 @@ if "%CMAKE_VERSION%" LSS "cmake version 3.11.4" ( ) :: NOTE Boost < 1.64 doesn't work without tricks if the user has only VS 2017 installed and no earlier versions. -set BOOST_VERSION=1.79.0 +set BOOST_VERSION=1.86.0 :: Version string with underscores instead of dots. set BOOST_VER=%BOOST_VERSION:.=_% @@ -169,7 +169,7 @@ cd "%DEPS_DIR%" :: VERSIONS set HDF5_VERSION=1.8.22 set HDF5_VERSION_MAJOR=1.8 -set OCCT_VERSION=7.5.3 +set OCCT_VERSION=7.8.1 :: NOTE If updating the default Python version, change PY_VER_MAJOR_MINOR accordingly in run-cmake.bat set PYTHON_VERSION=3.11.7 diff --git a/win/patches/V7_7_2.patch b/win/patches/V7_7_2.patch new file mode 100644 index 0000000000..64bf0830ef --- /dev/null +++ b/win/patches/V7_7_2.patch @@ -0,0 +1,86 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 1bacca1a48..5e788708c8 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -432,6 +432,10 @@ else() + set (USE_D3D OFF) + endif() + ++# IfcOpenShell begin ++add_definitions(-DHAVE_NO_DLL) ++# IfcOpenShell end ++ + # Enable/Disable the floating point exceptions (FPE) during runtime. + if (NOT BUILD_ENABLE_FPE_SIGNAL_HANDLER) + set (BUILD_ENABLE_FPE_SIGNAL_HANDLER OFF CACHE BOOL "${BUILD_ENABLE_FPE_SIGNAL_HANDLER_DESCR}" FORCE) +@@ -820,6 +824,8 @@ else() + OCCT_CHECK_AND_UNSET ("3RDPARTY_DOT_EXECUTABLE") + endif() + ++list (REMOVE_ITEM BUILD_TOOLKITS ExpToCasExe) ++ + # bison + if (BUILD_YACCLEX) + OCCT_INCLUDE_CMAKE_FILE ("adm/cmake/bison") +diff --git a/adm/cmake/occt_defs_flags.cmake b/adm/cmake/occt_defs_flags.cmake +index 33b50e8ce7..6b0628235e 100644 +--- a/adm/cmake/occt_defs_flags.cmake ++++ b/adm/cmake/occt_defs_flags.cmake +@@ -153,7 +153,23 @@ elseif (DEFINED CMAKE_COMPILER_IS_GNUCXX) + set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -s") + endif() + +-if (BUILD_RELEASE_DISABLE_EXCEPTIONS) +- set (CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -DNo_Exception") +- set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -DNo_Exception") +-endif() ++# IfcOpenShell begin ++# https://salsa.debian.org/science-team/opencascade/blob/739e7e9c4fbe575165eb3ff0251774bfad27139e/debian/patches/enable-exception-handling.patch ++# if (BUILD_RELEASE_DISABLE_EXCEPTIONS) ++# set (CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -DNo_Exception") ++# set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -DNo_Exception") ++# endif() ++ ++if (MSVC AND MSVC_STATIC_RUNTIME) ++ foreach(flag CMAKE_CXX_FLAGS CMAKE_CXX_FLAGS_DEBUG CMAKE_CXX_FLAGS_RELEASE CMAKE_CXX_FLAGS_MINSIZEREL ++ CMAKE_CXX_FLAGS_RELWITHDEBINFO CMAKE_C_FLAGS CMAKE_C_FLAGS_DEBUG CMAKE_C_FLAGS_RELEASE ++ CMAKE_C_FLAGS_MINSIZEREL CMAKE_C_FLAGS_RELWITHDEBINFO) ++ if (${flag} MATCHES "/MD") ++ string(REGEX REPLACE "/MD" "/MT" ${flag} "${${flag}}") ++ endif() ++ if (${flag} MATCHES "/MDd") ++ string(REGEX REPLACE "/MDd" "/MTd" ${flag} "${${flag}}") ++ endif() ++ endforeach() ++endif() ++# IfcOpenShell end +diff --git a/adm/cmake/occt_toolkit.cmake b/adm/cmake/occt_toolkit.cmake +index c9399159f1..0aa55392f9 100644 +--- a/adm/cmake/occt_toolkit.cmake ++++ b/adm/cmake/occt_toolkit.cmake +@@ -284,15 +284,22 @@ else() + else() + set (aReleasePdbConf) + endif() ++ # IfcOpenShell begin + if (BUILD_SHARED_LIBS) + install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/bin\${OCCT_INSTALL_BIN_LETTER}/${PROJECT_NAME}.pdb + CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo + DESTINATION "${INSTALL_DIR_BIN}\${OCCT_INSTALL_BIN_LETTER}") + else() +- install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/lib\${OCCT_INSTALL_BIN_LETTER}/${PROJECT_NAME}.pdb +- CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo +- DESTINATION "${INSTALL_DIR_LIB}\${OCCT_INSTALL_BIN_LETTER}") ++ set_target_properties (${PROJECT_NAME} PROPERTIES ++ COMPILE_PDB_NAME ${PROJECT_NAME} ++ COMPILE_PDB_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/pdb/ ++ ) ++ ++ install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/pdb/\${CMAKE_INSTALL_CONFIG_NAME}/${PROJECT_NAME}.pdb ++ CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo ++ DESTINATION "${INSTALL_DIR_LIB}\${OCCT_INSTALL_BIN_LETTER}") + endif() ++ # IfcOpenShell end + endif() + + if (BUILD_SHARED_LIBS AND NOT "${BUILD_SHARED_LIBRARY_NAME_POSTFIX}" STREQUAL "") diff --git a/win/patches/V7_8_1.patch b/win/patches/V7_8_1.patch new file mode 100644 index 0000000000..842a52bad6 --- /dev/null +++ b/win/patches/V7_8_1.patch @@ -0,0 +1,86 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 86905287dc..5a3a0a4eeb 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -440,6 +440,10 @@ else() + set (USE_D3D OFF) + endif() + ++# IfcOpenShell begin ++add_definitions(-DHAVE_NO_DLL) ++# IfcOpenShell end ++ + # Enable/Disable the floating point exceptions (FPE) during runtime. + if (NOT BUILD_ENABLE_FPE_SIGNAL_HANDLER) + set (BUILD_ENABLE_FPE_SIGNAL_HANDLER OFF CACHE BOOL "${BUILD_ENABLE_FPE_SIGNAL_HANDLER_DESCR}" FORCE) +@@ -828,6 +832,8 @@ else() + OCCT_CHECK_AND_UNSET ("3RDPARTY_DOT_EXECUTABLE") + endif() + ++list (REMOVE_ITEM BUILD_TOOLKITS ExpToCasExe) ++ + # bison + if (BUILD_YACCLEX) + OCCT_INCLUDE_CMAKE_FILE ("adm/cmake/bison") +diff --git a/adm/cmake/occt_defs_flags.cmake b/adm/cmake/occt_defs_flags.cmake +index 3d989abcd4..9c063a5c0d 100644 +--- a/adm/cmake/occt_defs_flags.cmake ++++ b/adm/cmake/occt_defs_flags.cmake +@@ -185,7 +185,23 @@ elseif (DEFINED CMAKE_COMPILER_IS_GNUCXX) + set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -s") + endif() + +-if (BUILD_RELEASE_DISABLE_EXCEPTIONS) +- set (CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -DNo_Exception") +- set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -DNo_Exception") +-endif() ++# IfcOpenShell begin ++# https://salsa.debian.org/science-team/opencascade/blob/739e7e9c4fbe575165eb3ff0251774bfad27139e/debian/patches/enable-exception-handling.patch ++# if (BUILD_RELEASE_DISABLE_EXCEPTIONS) ++# set (CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -DNo_Exception") ++# set (CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -DNo_Exception") ++# endif() ++ ++if (MSVC AND MSVC_STATIC_RUNTIME) ++ foreach(flag CMAKE_CXX_FLAGS CMAKE_CXX_FLAGS_DEBUG CMAKE_CXX_FLAGS_RELEASE CMAKE_CXX_FLAGS_MINSIZEREL ++ CMAKE_CXX_FLAGS_RELWITHDEBINFO CMAKE_C_FLAGS CMAKE_C_FLAGS_DEBUG CMAKE_C_FLAGS_RELEASE ++ CMAKE_C_FLAGS_MINSIZEREL CMAKE_C_FLAGS_RELWITHDEBINFO) ++ if (${flag} MATCHES "/MD") ++ string(REGEX REPLACE "/MD" "/MT" ${flag} "${${flag}}") ++ endif() ++ if (${flag} MATCHES "/MDd") ++ string(REGEX REPLACE "/MDd" "/MTd" ${flag} "${${flag}}") ++ endif() ++ endforeach() ++endif() ++# IfcOpenShell end +diff --git a/adm/cmake/occt_toolkit.cmake b/adm/cmake/occt_toolkit.cmake +index c9399159f1..0aa55392f9 100644 +--- a/adm/cmake/occt_toolkit.cmake ++++ b/adm/cmake/occt_toolkit.cmake +@@ -284,15 +284,22 @@ else() + else() + set (aReleasePdbConf) + endif() ++ # IfcOpenShell begin + if (BUILD_SHARED_LIBS) + install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/bin\${OCCT_INSTALL_BIN_LETTER}/${PROJECT_NAME}.pdb + CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo + DESTINATION "${INSTALL_DIR_BIN}\${OCCT_INSTALL_BIN_LETTER}") + else() +- install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/lib\${OCCT_INSTALL_BIN_LETTER}/${PROJECT_NAME}.pdb +- CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo +- DESTINATION "${INSTALL_DIR_LIB}\${OCCT_INSTALL_BIN_LETTER}") ++ set_target_properties (${PROJECT_NAME} PROPERTIES ++ COMPILE_PDB_NAME ${PROJECT_NAME} ++ COMPILE_PDB_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/pdb/ ++ ) ++ ++ install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/pdb/\${CMAKE_INSTALL_CONFIG_NAME}/${PROJECT_NAME}.pdb ++ CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo ++ DESTINATION "${INSTALL_DIR_LIB}\${OCCT_INSTALL_BIN_LETTER}") + endif() ++ # IfcOpenShell end + endif() + + if (BUILD_SHARED_LIBS AND NOT "${BUILD_SHARED_LIBRARY_NAME_POSTFIX}" STREQUAL "") diff --git a/win/run-cmake.bat b/win/run-cmake.bat index a52b079ea2..abadbdf96c 100755 --- a/win/run-cmake.bat +++ b/win/run-cmake.bat @@ -80,7 +80,7 @@ IF NOT EXIST ..\%BUILD_DIR%. mkdir ..\%BUILD_DIR% pushd ..\%BUILD_DIR% :: tfk: todo remove duplication -set BOOST_VERSION=1.79.0 +set BOOST_VERSION=1.86.0 set BOOST_VER=%BOOST_VERSION:.=_% set BOOST_ROOT=%DEPS_DIR%\boost_%BOOST_VER%