Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Schultz 7c738c15d9 Fix slab layer geometry for rotated and angled slabs
Three bugs fixed:

1. EditAssignedMaterial was sweeping all slabs that share a layer set
   when changing material properties, instead of updating only the
   selected element's assigned material.

2. slice_layerset_mesh (loader.py): layer bisect positions were scaled
   incorrectly due to a stale unit_scale and a reversed/missing
   DirectionSense guard. Now always recalculates unit_scale fresh and
   correctly applies sense_factor.

3. change_thickness (slab.py): extrusion depth was computed as
   `thickness / cos(obj.rotation_euler.x)`, which incorrectly scaled
   ObjectPlacement-rotated slabs (where the extrusion direction is
   local Z and no scaling is needed). The correct formula is
   `thickness / extrusion_vec.z`, which handles both ObjectPlacement
   rotation (extrusion_vec.z ≈ 1.0 → no scale) and ExtrudedDirection
   tilts (extrusion_vec.z < 1.0 → scale up) uniformly. The resulting
   slab was 1.414× too thick for 45°-rotated slabs, making both
   material layers appear fatter than specified.

   slice_layerset_mesh retains a depth_scale safety factor
   (extrusion_vec.z × ifc_depth / total_layer_thickness) as a
   robustness guard for IFC files from other authoring tools where
   extrusion depth may not match the sum of LayerThicknesses.
2026-03-14 12:21:54 -05:00
Ryan Schultz 036ee098a6 Fix slab layer geometry: custom offset isolation, unit scale, and layer ordering
Three related bugs fixed in the slab material layer set workflow:

1. EditAssignedMaterial (operator.py): Applying a custom offset to one slab
   instance incorrectly regenerated geometry for ALL slabs sharing the same
   IfcMaterialLayerSet. Replaced regenerate_from_layer_set (sweeps all users)
   with per-element regenerate_from_occurence for AXIS3 slabs and targeted
   recalculate_walls for AXIS2 walls. Each element's IfcMaterialLayerSetUsage
   attributes are now updated individually before regeneration.

2. slice_layerset_mesh (loader.py): Loader.unit_scale is a class variable only
   set during full file import, so it was stale (= 1) during live geometry
   updates on foot-based IFC files. Layer bisect planes were being computed in
   IFC feet while the mesh was in Blender metres, placing all cuts completely
   outside the mesh. Fixed by computing unit_scale fresh from the IFC file on
   each call via ifcopenshell.util.unit.calculate_unit_scale.

3. OffsetFromReferenceLine stale / layer order reversed (slab.py, loader.py):
   A guard (and custom_offset is None) in change_thickness prevented writing
   the correct OffsetFromReferenceLine (position.z) to the usage when a custom
   offset was active. This left the value at 0.0 instead of the actual slab
   bottom (e.g. -1.0 IFC units for a TOP-reference slab), so the bisect
   starting point co was at the reference plane rather than the slab bottom,
   reversing layer assignments or missing layers entirely. Removed the guard —
   safe because each element has its own IfcMaterialLayerSetUsage instance.
   Reverted the AXIS3 bisect normal back to (0,0,1) (upward from co at slab
   bottom) which is correct once OffsetFromReferenceLine is properly set.
2026-03-14 12:21:35 -05:00
339 changed files with 2213 additions and 14575 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: mac-${{ matrix.arch }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ubuntu-22.04-${{ runner.arch }}
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
+4 -7
View File
@@ -7,9 +7,6 @@ on:
jobs:
lint-formatting:
runs-on: ubuntu-latest
env:
MIN_IOS_PY_VERSION: "3.10"
MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v6
@@ -17,12 +14,12 @@ jobs:
- name: Action - install python
uses: actions/setup-python@v6
with:
python-version: ${{ env.MIN_IOS_PY_VERSION }}
python-version: "3.10"
- name: Action - install python
uses: actions/setup-python@v6
with:
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
python-version: "3.11"
- name: Install dependencies
run: |
@@ -38,8 +35,8 @@ jobs:
ERROR=0
# Using 2 Python versions - one minimum required for IfcOpenShell
# and other that's used by Blender currently.
python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1
python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1
python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1
python3.11 -W error -m compileall -q src/bonsai || ERROR=1
exit $ERROR
continue-on-error: true
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
@@ -84,7 +84,7 @@ jobs:
run: |
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
-
name: Build ifcopenshell
@@ -91,7 +91,7 @@ jobs:
lfs: true
- name: Download
uses: actions/download-artifact@v8.0.1
uses: actions/download-artifact@v8.0.0
with:
# Artifact name
name: ifcos-artifacts
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py310, py311, py312, py313, py314]
pyver: [py39, py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py310, py311, py312, py313, py314]
pyver: [py39, py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
+1 -2
View File
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ubuntu-22.04-${{ runner.arch }}
@@ -254,7 +254,6 @@ jobs:
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifctester --no-deps
cd ../ifctester && make test || ERROR=1
make build-ids-docs || ERROR=1
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
cd ../ifcopenshell-python
pip install mathutils
@@ -31,7 +31,7 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@v5
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v4
@@ -47,4 +47,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4
+3 -16
View File
@@ -5,8 +5,6 @@
/_installed-vs*-x*/
/build/
/src/examples/build/
# ifctester docs output
/src/ifctester/test/build/
# output directories
/cmake/out/
@@ -14,7 +12,6 @@
/src/ifcmax/out/
/src/ifcwrap/out/
/src/qtviewer/out/
/src/ifctester/webapp/public/pyodide/
/win/BuildDepsCache*.txt
@@ -83,14 +80,10 @@ src/ifcopenshell-python/test/build
# bonsai i18n
src/bonsai/bonsai/translations.py
# bonsai external dependencies (cloned for just ty checks)
src/bonsai/external_dependencies/
# bonsai test temp/cache files
# bonsai test temp files
src/bonsai/test/files/temp
src/bonsai/test/files/*.cache.blend
src/bonsai/test/files/*.cache.json
src/bonsai/test/files/*.cache.sqlite
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
@@ -122,9 +115,3 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# temp files from AI coding tools
*.claude
*.py.tmp*
*.json.tmp*
+10 -19
View File
@@ -13,7 +13,6 @@ import hashlib
import os
import pathlib
import re
import subprocess
from typing import NoReturn
from urllib import request
@@ -21,7 +20,7 @@ from github import Github
def get_repo_tag_names() -> list[str]:
git_return = subprocess.check_output("git tag -l", text=True)
git_return = os.popen("git tag -l").read()
tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name]
print(f"{len(tag_names)} tag_names found in repo")
return tag_names
@@ -79,10 +78,6 @@ def get_release_zip(tag: str) -> tuple[str, str]:
raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.")
def run(command: str) -> None:
subprocess.check_output(command)
start = datetime.datetime.now()
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
@@ -102,7 +97,7 @@ should_release = False
target_release_tag = ""
TARGET_OS = "windows-x64"
git_status = subprocess.check_output("git status", text=True)
git_status = os.popen("git status").read()
print(git_status)
for tag_name in get_repo_tag_names():
@@ -152,7 +147,7 @@ blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
# url_blenderbim_py3x_win_zip
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose")
os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read()
# sha256sum_blenderbim_py310_win_zip
sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name)
@@ -206,13 +201,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful")
print("\n_____ build choco.exe with mono")
choco_version = "1.1.0"
run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet")
run(f"tar -xzf {choco_version}.tar.gz")
os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read()
os.popen(f"tar -xzf {choco_version}.tar.gz").read()
print("choco tar unpack successful")
os.chdir("choco-1.1.0")
run("./build.sh")
os.popen("./build.sh").read()
run("cp -r build_output/chocolatey /opt/chocolatey")
os.popen("cp -r build_output/chocolatey /opt/chocolatey").read()
os.chdir(BLENDERBIM_DIR)
if pathlib.Path("/opt/chocolatey/choco.exe").exists():
@@ -220,15 +215,11 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists():
print("\n_____ build choco pack")
run("mono /opt/chocolatey/choco.exe pack --allow-unofficial")
run(
'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial'
)
os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read()
os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read()
print("\n_____ build choco push")
run(
'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose'
)
os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read()
print(f"choco push of version: {target_release_tag} successful!")
print(f"it took: {datetime.datetime.now() - start}")
-3
View File
@@ -41,9 +41,6 @@ def pack_dependencies(install_dir: Path) -> None:
if not dependency_path.is_dir():
continue
dependency_name = dependency_path.name
# Skip ifcopenshell - it's a build output, not a dependency to reuse across builds.
if dependency_name == "ifcopenshell":
continue
tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz"
if tar_path.exists():
print(f"Skipping existing cache: '{tar_path}'")
+2 -176
View File
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.8",
"ruff==0.15.5",
"poethepoet",
"gersemi==0.26.1",
"gersemi==0.26.0",
]
[tool.black]
@@ -78,139 +78,6 @@ ignore = [
"UP032", # Replace .format with f-string
]
[tool.ty.rules]
all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
byte-string-type-annotation = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
cyclic-type-alias-definition = "error"
dataclass-field-order = "error"
duplicate-base = "error"
duplicate-kw-only = "error"
empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
fstring-type-annotation = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
ineffective-final = "error"
instance-layout-conflict = "error"
invalid-dataclass = "error"
invalid-dataclass-override = "error"
invalid-enum-member-annotation = "error"
invalid-explicit-override = "error"
invalid-frozen-dataclass-subclass = "error"
invalid-generic-class = "error"
invalid-generic-enum = "error"
invalid-ignore-comment = "error"
invalid-legacy-positional-parameter = "error"
invalid-legacy-type-variable = "error"
invalid-named-tuple = "error"
invalid-newtype = "error"
invalid-overload = "error"
invalid-paramspec = "error"
invalid-protocol = "error"
invalid-syntax-in-forward-annotation = "error"
invalid-total-ordering = "error"
invalid-type-alias-type = "error"
invalid-type-checking-constant = "error"
invalid-type-guard-definition = "error"
invalid-type-variable-bound = "error"
invalid-type-variable-constraints = "error"
invalid-typed-dict-header = "error"
invalid-typed-dict-statement = "error"
override-of-final-method = "error"
override-of-final-variable = "error"
possibly-missing-import = "error"
possibly-missing-submodule = "error"
# Has false positives due to ty walrus operator bug.
# possibly-unresolved-reference = "error"
raw-string-type-annotation = "error"
redundant-final-classvar = "error"
shadowed-type-variable = "error"
subclass-of-final-class = "error"
super-call-in-named-tuple-method = "error"
unavailable-implicit-super-arguments = "error"
unbound-type-variable = "error"
undefined-reveal = "error"
unresolved-global = "error"
unresolved-import = "error"
unresolved-reference = "error"
unused-ignore-comment = "error"
unused-type-ignore-comment = "error"
useless-overload-body = "error"
# Non-structural rules:
deprecated = "error"
zero-stepsize-in-slice = "error"
possibly-missing-implicit-call = "error"
unused-awaitable = "error"
# Function argument rules:
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
# call-non-callable = "error"
conflicting-argument-forms = "error"
# Too many false positives.
# invalid-argument-type = "error"
missing-argument = "error"
parameter-already-assigned = "error"
positional-only-parameter-as-kwarg = "error"
too-many-positional-arguments = "error"
unknown-argument = "error"
# Has a lot of warnings due to current ty walrus operator issues.
# index-out-of-bounds = "error"
# unresolved-attribute = "error"
[tool.ty.environment]
extra-paths = [
"src/bonsai/external_dependencies",
"src/bcf",
"src/bsdd",
"src/bonsai",
"src/ifc4d",
"src/ifc5d",
"src/ifccityjson",
"src/ifcclash",
"src/ifccsv",
"src/ifcdiff",
"src/ifcfm",
"src/ifcopenshell-python",
"src/ifcpatch",
"src/ifctester",
]
[tool.ty.src]
exclude = [
# External dependencies cloned for type checking only.
"src/bonsai/external_dependencies",
# Submodules.
"src/ifcopenshell-python/ifcopenshell/express",
"src/ifcopenshell-python/ifcopenshell/mvd",
"src/ifcopenshell-python/ifcopenshell/simple_spf",
"src/svgfill/3rdparty",
# Has special dependencies.
"src/ifcopenshell-python/ifcopenshell/geom/app.py",
"src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py",
"src/ifcopenshell-python/ifcopenshell/util/doc.py",
"src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py",
"src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py",
# Too esoteric.
"src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py",
"src/ifc2ca/templates",
# Too dev.
"src/bcf/setup.py",
"src/bsdd/yml_to_classes.py",
# Deprecated.
"src/ifc2ca/_deprecated",
]
[tool.poe.tasks]
ruff-main = "ruff check --extend-exclude nix/build-all.py"
@@ -220,47 +87,6 @@ ruff.sequence = ["ruff-main", "ruff-old"]
black = "black ."
ty.sequence = ["ty-bonsai", "ty-ios"]
ty.help = "Run ty type checker. Requires ty-venv to be set up first."
ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
ty-venv-bonsai.sequence = [
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
{cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"},
]
ty-venv-ios.sequence = [
{cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"},
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
]
format.sequence = ["black", "ruff-main", "ruff-old"]
cmake-format = "gersemi . --in-place"
[tool.poe.tasks.ty-ios]
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
src/bcf
src/bsdd
src/ifc2ca
src/ifc4d
src/ifc5d
src/ifccityjson
src/ifcclash
src/ifccsv
src/ifcdiff
src/ifcfm
src/ifcopenshell-python
src/ifcpatch
src/ifctester
--python=src/ifcopenshell-python/.venv
--ignore unresolved-reference
"""
[tool.poe.tasks.bonsai-deps]
help = "Clone or update Bonsai external dependencies."
cmd = "python src/bonsai/scripts/bonsai_deps.py"
+3 -3
View File
@@ -34,8 +34,8 @@ client_id, client_secret = "", ""
class OAuthReceiver(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
self.server.auth_code = query.get("code", [""])[0]
self.server.auth_state = query.get("state", [""])[0]
self.server.auth_code = query.get("code", [""])[0] # type: ignore
self.server.auth_state = query.get("state", [""])[0] # type: ignore
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
@@ -255,7 +255,7 @@ class BcfClient:
project_id: str = "",
topics: str = "",
query_string: Optional[str] = None,
) -> None:
) -> list[Any]:
# return self.get(
# f"/projects/{project_id}/topics",
# {
+10 -14
View File
@@ -173,17 +173,16 @@ def assert_viewpoints(viewpoints):
assert viewpoint.snapshot is not None
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=False,
),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=False,
),
exceptions=expected_exception,
default_visibility=False,
),
@@ -194,7 +193,6 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266),
camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048),
field_of_view=60,
aspect_ratio=1.0,
),
guid="21dd4807-e9af-439e-a980-04d913a6b1ce",
)
@@ -202,17 +200,16 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
assert viewpoint.snapshot is not None
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=True,
),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=True,
),
exceptions=expected_exception,
default_visibility=True,
),
@@ -223,7 +220,6 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex
camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428),
camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241),
field_of_view=60,
aspect_ratio=1.0,
),
guid="81daa431-bf01-4a49-80a2-1ab07c177717",
)
+2 -1
View File
@@ -232,7 +232,8 @@ endif
cd build/bonsai/bim/data/brick/ && wget https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl
# Required for hipped roof generation
cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/prochitecture/bpypolyskel" --no-deps -w wheels/
# TODO: Use official repo once https://github.com/prochitecture/bpypolyskel/pull/22 is merged.
cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/Andrej730/bpypolyskel.git@pyproject_toml" --no-deps -w wheels/
# folder for executable files
mkdir -p build/bonsai/libs/bin
+3 -1
View File
@@ -72,7 +72,9 @@ class IfcExporter:
def set_header(self):
self.file.header.file_name.name = os.path.basename(self.ifc_export_settings.output_file)
self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat()
self.file.header.file_name.time_stamp = (
datetime.datetime.utcnow().replace(tzinfo=datetime.UTC).astimezone().replace(microsecond=0).isoformat()
)
self.file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version)
self.file.header.file_name.originating_system = "{} {}".format(
self.get_application_name(), tool.Blender.get_bonsai_version()
+5 -8
View File
@@ -45,19 +45,16 @@ from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
global_subscription_owner = object()
# Separate owner for per-object msgbus subscriptions (name, active_material_index).
# Using a dedicated owner allows clearing all per-object subscriptions at once
# during undo/redo without affecting other global subscriptions.
object_subscription_owner = object()
def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None:
try:
obj.name
except:
# The object is invalid but somehow still has a callback.
# This can occur during undo/redo when the Python wrapper is stale.
return
# The object is invalid but somehow still has a callback. Clear all
# msgbus subscriptions to prevent useless further triggers.
bpy.msgbus.clear_by_owner(obj)
return # In case the object RNA is gone during an undo / redo operation
# Blender names are up to 63 UTF-8 bytes
if len(bytes(obj.name, "utf-8")) >= 63:
return
@@ -192,7 +189,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type
return
bpy.msgbus.subscribe_rna(
key=subscribe_to,
owner=object_subscription_owner,
owner=obj,
args=(
obj,
data_path,
+10 -12
View File
@@ -18,7 +18,6 @@
from __future__ import annotations
import gc
import hashlib
import os
import shutil
@@ -171,13 +170,7 @@ class IfcStore:
# No point to trying again the same operation.
return
# Force GC to release any dangling HDF5 C-level file handle (Windows).
gc.collect()
try:
os.remove(IfcStore.cache_path)
except PermissionError as e:
print(f"Could not delete locked cache file '{cache_path.name}': {str(e)}. Skipping cache.")
return
os.remove(IfcStore.cache_path)
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
@@ -323,8 +316,11 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
# Note: msgbus subscriptions are cleared globally during
# rebuild_element_maps which runs after every undo/redo.
obj = IfcStore.get_object_by_name(data["obj"])
if obj is None:
# obj was just created during this step and didn't existed before.
return
bpy.msgbus.clear_by_owner(obj)
@staticmethod
def commit_link_element(data: OperationData) -> None:
@@ -371,8 +367,10 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
# Note: msgbus subscriptions are cleared globally during
# rebuild_element_maps which runs after every undo/redo.
obj = IfcStore.get_object_by_name(data["obj"])
# obj might be removed after unlink.
if not obj:
bpy.msgbus.clear_by_owner(obj)
@staticmethod
def unlink_element(
+2 -2
View File
@@ -64,8 +64,8 @@ class MaterialCreator:
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)
if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or (
(rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep
):
return
@@ -101,7 +101,7 @@ class AggregateDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+1 -1
View File
@@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup):
def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global]
global RELATED_TOPICS_ENUM_ITEMS
props = self
active_topic = props.active_topic
active_related_topics = active_topic.related_topics.keys()
+4 -4
View File
@@ -46,26 +46,26 @@ def get_libraries(self, context):
def get_namespaces(self, context):
global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global]
global NAMESPACES_ENUM_ITEMS
NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces]
return NAMESPACES_ENUM_ITEMS
def get_brick_entity_classes(self, context):
global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global]
global ENTITY_CLASSES_ENUM_ITEMS
entity = self.brick_entity_create_type
ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]]
return ENTITY_CLASSES_ENUM_ITEMS
def get_brick_roots(self, context):
global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global]
global BRICK_ROOTS_ENUM_ITEMS
BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes]
return BRICK_ROOTS_ENUM_ITEMS
def get_brick_relations(self, context):
global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global]
global BRICK_RELATIONS_ENUM_ITEMS
BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships]
for relation in BrickschemaData.data["active_relations"]:
if relation["predicate_name"] == "label":
@@ -37,7 +37,6 @@ messages = {
class CadTrimExtend(bpy.types.Operator):
bl_idname = "bim.cad_trim_extend"
bl_label = "CAD Trim / Extend"
bl_description = "Extends/reduces element to 3D cursor"
@classmethod
def poll(cls, context):
@@ -83,7 +82,6 @@ class CadTrimExtend(bpy.types.Operator):
class CadMitre(bpy.types.Operator):
bl_idname = "bim.cad_mitre"
bl_label = "CAD Mitre"
bl_description = "Joins two non-parallel paths at their intersection"
@classmethod
def poll(cls, context):
+18 -56
View File
@@ -106,37 +106,23 @@ class CadTool(WorkSpaceTool):
)
row = layout.row(align=True)
add_layout_hotkey_operator(
row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
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", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
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", bpy.ops.bim.add_ifcarcindex_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__, ui_context)
elif (
isinstance(data, tool.Geometry.TYPES_WITH_MESH_PROPERTIES)
@@ -146,21 +132,15 @@ class CadTool(WorkSpaceTool):
layout, "Edit Axis", "bim.edit_extrusion_axis", "bim.disable_editing_extrusion_axis", ui_context
)
row = layout.row(align=True)
add_layout_hotkey_operator(
row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
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", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
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", bpy.ops.bim.cad_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.cad_fillet.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
else:
if (
@@ -188,37 +168,19 @@ class CadTool(WorkSpaceTool):
add_layout_hotkey_operator(row, "Set Gable Roof Angle", "S_R", "Set Gable Roof Angle", ui_context)
row = layout.row(align=True)
add_layout_hotkey_operator(
row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
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", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
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", bpy.ops.bim.add_ifcarcindex_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row,
"2-Point Arc",
"S_C",
bpy.ops.bim.cad_arc_from_2_points.__doc__.split("\n", 1)[1].strip(),
ui_context,
)
add_layout_hotkey_operator(row, "2-Point Arc", "S_C", bpy.ops.bim.cad_arc_from_2_points.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row,
"3-Point Arc",
"S_V",
bpy.ops.bim.cad_arc_from_3_points.__doc__.split("\n", 1)[1].strip(),
ui_context,
)
add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.cad_arc_from_3_points.__doc__, ui_context)
class CadHotkey(bpy.types.Operator):
@@ -1787,7 +1787,7 @@ class CutDecorator:
# Handle both old float64 and new float32 checksums for version compatibility
rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9).reshape(3, 3)
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
rot_real = tool.Blender.np_array_legacy(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))
@@ -1285,7 +1285,7 @@ class SnapManager:
continue
coords = np.empty(vertex_count * 3, dtype=np.float32)
mesh.vertices.foreach_get("co", coords)
mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type]
coords = coords.reshape(-1, 3)
matrix = np.array(obj_eval.matrix_world, dtype=np.float32)
@@ -456,8 +456,7 @@ def format_distance(
tx_dist = fmt % d_cm
else:
assert f"Unexpected unit_system - '{unit_system}'."
# tx_dist = fmt % value
tx_dist = fmt % value
return tx_dist
@@ -1426,7 +1426,6 @@ class CreateDrawing(bpy.types.Operator):
"/Pset_.*Common/.Status",
"EPset_Status.Status",
"EPset_Status.UserDefinedStatus",
"Material.Name",
]
group = root.find("{http://www.w3.org/2000/svg}g")
@@ -3306,8 +3305,9 @@ class AddTextLiteral(bpy.types.Operator):
attr.data_type = "string"
attr.string_value = literal_attr_values[attr_name]
literal_props.align_vertical = "bottom"
literal_props.align_horizontal = "left"
box_alignment_mask = [False] * 9
box_alignment_mask[6] = True # bottom_left box_alignment
literal_props.box_alignment = box_alignment_mask
return {"FINISHED"}
@@ -3365,55 +3365,57 @@ class OrderTextLiteralDown(bpy.types.Operator):
return {"FINISHED"}
class AssignSelectedObjectAsProduct(bpy.types.Operator, tool.Ifc.Operator):
# Ifc Operator is unnecessary, because suboperator is handling IFC changes.
class AssignSelectedObjectAsProduct(bpy.types.Operator):
bl_idname = "bim.assign_selected_as_product"
bl_label = "Assign Selected Object As Product"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if len(context.selected_objects) < 2:
cls.poll_message_set("At least 2 objects need to be selected")
if len(context.selected_objects) != 2:
cls.poll_message_set("2 objects need to be selected")
return False
return True
def _execute(self, context):
def execute(self, context):
assert bpy.context.view_layer
objs = context.selected_objects[:]
ifc_objs = [(o, tool.Ifc.get_entity(o)) for o in objs if tool.Ifc.get_entity(o)]
obj1, obj2 = objs
element1 = tool.Ifc.get_entity(obj1)
element2 = tool.Ifc.get_entity(obj2)
assert element1 and element2
annotations = [(o, e) for o, e in ifc_objs if e.is_a("IfcAnnotation")]
non_annotations = [(o, e) for o, e in ifc_objs if not e.is_a("IfcAnnotation")]
# Check if at least one object is an IfcAnnotation
is_annotation1 = element1.is_a("IfcAnnotation")
is_annotation2 = element2.is_a("IfcAnnotation")
if not annotations:
self.report({"ERROR"}, "At least one selected object must be an IfcAnnotation.")
if not (is_annotation1 or is_annotation2):
self.report({"ERROR"}, "At least one of the selected objects must be IfcAnnotation.")
return {"CANCELLED"}
if len(non_annotations) == 1:
# One product, one or more annotations — assign all annotations to the product.
product = non_annotations[0][1]
elif len(non_annotations) == 0 and len(annotations) == 2:
# Both objects are annotations — use the non-active one as the relating product.
# If both are annotations, use the currently active object as relating product
if is_annotation1 and is_annotation2:
active_obj = context.active_object
if annotations[0][0] == active_obj:
annotation_obj, annotation = annotations[0]
product = annotations[1][1]
if active_obj == obj1:
other_selected_object = obj1
bpy.context.view_layer.objects.active = obj2
else:
annotation_obj, annotation = annotations[1]
product = annotations[0][1]
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
tool.Blender.update_viewport()
return
other_selected_object = obj2
bpy.context.view_layer.objects.active = obj1
# If only one is an annotation, make it the active object
elif is_annotation1:
other_selected_object = obj2
bpy.context.view_layer.objects.active = obj1
else:
self.report(
{"ERROR"},
"Select exactly one product object and one or more IfcAnnotation objects.",
)
return {"CANCELLED"}
other_selected_object = obj1
bpy.context.view_layer.objects.active = obj2
for annotation_obj, _ in annotations:
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
tool.Blender.update_viewport()
assert (active_obj := context.active_object)
props = tool.Drawing.get_object_assigned_product_props(active_obj)
props.relating_product = other_selected_object
bpy.ops.bim.edit_assigned_product()
return {"FINISHED"}
class EditAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
@@ -3885,7 +3887,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
ifc_file = tool.Ifc.get()
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
params = {"check_existing": False}
image = load_image(abs_path.name, str(abs_path.parent), **params)
mesh = bpy.data.meshes.new(image_filepath.stem)
obj = bpy.data.objects.new(image_filepath.stem, mesh)
@@ -4175,7 +4178,10 @@ class SelectSimilarTextLiteralValue(bpy.types.Operator):
should_select = True
break
elif self.attribute_type == "box_alignment":
if literal.get_box_alignment() == self.literal_value:
box_alignment_attr = next(
(attr for attr in literal.attributes if attr.name == "BoxAlignment"), None
)
if box_alignment_attr and box_alignment_attr.string_value == self.literal_value:
should_select = True
break
+44 -36
View File
@@ -27,6 +27,7 @@ import ifcopenshell.api.pset
import ifcopenshell.util.element
from bpy.props import (
BoolProperty,
BoolVectorProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
@@ -672,6 +673,20 @@ class BIMCameraProperties(PropertyGroup):
return ortho_scale, aspect_ratio
DEFAULT_BOX_ALIGNMENT = [False] * 6 + [True] + [False] * 2
BOX_ALIGNMENT_POSITIONS = [
"top-left",
"top-middle",
"top-right",
"middle-left",
"center",
"middle-right",
"bottom-left",
"bottom-middle",
"bottom-right",
]
class ElementValueRow(PropertyGroup):
"""Represents a single element value row with category, key, and formatted value"""
@@ -774,38 +789,40 @@ def get_category_items_with_counts(self, context):
class LiteralProps(PropertyGroup):
attributes: CollectionProperty(name="Attributes", type=Attribute)
ifc_definition_id: IntProperty(name="IFC definition ID", default=0)
align_horizontal: EnumProperty(
items=[
("left", "Left", "", "ALIGN_LEFT", 0),
("middle", "Middle", "", "ALIGN_CENTER", 1),
("right", "Right", "", "ALIGN_RIGHT", 2),
],
default="left",
name="Horizontal Alignment",
)
align_vertical: EnumProperty(
items=[
("top", "Top", "", "ALIGN_TOP", 0),
("middle", "Middle", "", "ALIGN_MIDDLE", 1),
("bottom", "Bottom", "", "ALIGN_BOTTOM", 2),
],
default="middle",
name="Vertical Alignment",
)
def set_box_alignment(self, new_value):
markers = new_value.count(True)
if not markers:
return
def get_box_alignment(self) -> str:
alignment = self.align_vertical + "-" + self.align_horizontal
if alignment == "middle-middle":
alignment = "center"
return alignment
if markers > 1:
prev_value = self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
# looking for the first value changed to positive
first_changed_value = next((i for i in range(9) if new_value[i] and new_value[i] != prev_value[i]), None)
# if nothing have changed we just keep the previous value
if first_changed_value is None:
return
new_value = [False] * 9
new_value[first_changed_value] = True
self["box_alignment"] = new_value
position_string = BOX_ALIGNMENT_POSITIONS[next(i for i in range(9) if new_value[i])]
self.attributes["BoxAlignment"].set_value(position_string)
def get_box_alignment(self):
return self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
attributes: CollectionProperty(name="Attributes", type=Attribute)
box_alignment: BoolVectorProperty(
name="Box alignment", size=9, set=set_box_alignment, get=get_box_alignment, default=DEFAULT_BOX_ALIGNMENT
)
ifc_definition_id: IntProperty(name="IFC definition ID", default=0)
def get_literal_edited_data(self) -> dict[str, str]:
text_data = {
"CurrentValue": self.attributes["Literal"].string_value,
"Literal": self.attributes["Literal"].string_value,
"BoxAlignment": self.get_box_alignment(),
"BoxAlignment": self.attributes["BoxAlignment"].string_value,
}
return text_data
@@ -843,19 +860,12 @@ class LiteralProps(PropertyGroup):
if TYPE_CHECKING:
attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
value: str
box_alignment: tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool]
ifc_definition_id: int
align_horizontal: str
align_vertical: str
element_value_rows: bpy.types.bpy_prop_collection_idprop[ElementValueRow]
category_for_adding: str
def update_text_alignment(self, context):
for literal_props in self.literals:
literal_props.align_horizontal = self.align_horizontal
literal_props.align_vertical = self.align_vertical
class BIMTextProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
literals: CollectionProperty(name="Literals", type=LiteralProps)
@@ -889,7 +899,6 @@ class BIMTextProperties(PropertyGroup):
],
default="left",
name="Horizontal Alignment",
update=update_text_alignment,
)
align_vertical: EnumProperty(
items=[
@@ -899,7 +908,6 @@ class BIMTextProperties(PropertyGroup):
],
default="middle",
name="Vertical Alignment",
update=update_text_alignment,
)
if TYPE_CHECKING:
@@ -366,6 +366,9 @@ class BaseLinesShader(BaseShader):
}
"""
def __init__(self, gap_size=16):
super().__init__(gap_size=gap_size)
def glenable(self):
super().glenable()
+28 -4
View File
@@ -781,10 +781,33 @@ class BIM_PT_text(Panel):
if other_attributes:
bonsai.bim.helper.draw_attributes(other_attributes, box)
row = box.row()
row.label(text="Alignment")
row.prop(literal_props, "align_horizontal", text="", expand=True)
row.prop(literal_props, "align_vertical", text="", expand=True)
row = box.row(align=True)
cols = [row.column(align=True) for j in range(3)]
for j in range(9):
cols[j % 3].prop(
literal_props,
"box_alignment",
text="",
index=j,
icon="RADIOBUT_ON" if literal_props.box_alignment[j] else "RADIOBUT_OFF",
)
col = row.column(align=True)
alignment_label_row = col.row(align=True)
alignment_label_row.label(text=" Text box alignment:")
box_alignment_value = (
literal_props.attributes[
next(
(idx for idx, attr in enumerate(literal_props.attributes) if attr.name == "BoxAlignment"),
-1,
)
].string_value
if any(attr.name == "BoxAlignment" for attr in literal_props.attributes)
else "N/A"
)
col.label(text=f" {box_alignment_value}")
def draw(self, context):
obj = context.active_object
@@ -816,6 +839,7 @@ class BIM_PT_text(Panel):
for i, literal_data in enumerate(text_data["Literals"]):
box = self.layout.box()
box.label(text=f"Literal[{i}]:")
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
for attribute in literal_data:
@@ -1066,7 +1066,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
cls.poll_message_set("Only available from Outliner.")
return False
def execute(self, context): # ty:ignore[override-of-final-method]
def execute(self, context):
if len(getattr(context, "selected_ids", [])) == 0:
return {"FINISHED"}
@@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
elif obj in pprops.clipping_planes_objs:
self.report({"ERROR"}, "Clipping planes cannot be edited")
elif element:
if not obj.data or obj.type not in ("MESH", "CURVE"):
if not obj.data:
self.report({"INFO"}, "No geometry to edit")
elif tool.Geometry.is_locked(element):
self.report({"ERROR"}, lock_error_message(obj.name))
@@ -139,9 +139,7 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
local_coordinates[0],
local_coordinates[1],
local_coordinates[2],
*local_coordinates,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -164,9 +162,7 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
local_coordinates[0],
local_coordinates[1],
local_coordinates[2],
*local_coordinates,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -271,8 +267,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str
x_axis_is_null: bool
model_is_georeferenced: bool
model_crs: str
model_origin: str
model_origin_si: str
model_project_north: str
+1 -1
View File
@@ -27,7 +27,7 @@ from bonsai.bim.prop import StrProperty
class BIMCityJsonProperties(PropertyGroup):
def get_lods(self, context):
global LODS_ENUM_ITEMS # ty: ignore[unresolved-global]
global LODS_ENUM_ITEMS
LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods]
return LODS_ENUM_ITEMS
+1 -1
View File
@@ -320,7 +320,7 @@ class RadianceExporterProperties(PropertyGroup):
)
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global]
global SUBCATEGORIES_ENUM_ITEMS
if self.category in spectraldb:
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
else:
@@ -102,6 +102,7 @@ class MaterialsData:
if (style_name := s.Name) is not None
]
results = natsorted(results, key=lambda i: i[1])
results.insert(0, ("-", "No Surface Style", ""))
return results
@classmethod
@@ -210,15 +210,14 @@ class AssignMaterialToSelected(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_material_to_selected"
bl_label = "Assign Material To Selected"
bl_description = (
"Assign currently selected material in Materials UI to the selected objects.\n"
"Occurrences automatically get usages for layer/profile sets.\n\n"
"ALT+CLICK to assign without a usage."
"Assign currently selected material in Materials UI to the selected objects.\n\n"
"ALT+CLICK to assign material as a usage."
)
bl_options = {"REGISTER", "UNDO"}
material: bpy.props.IntProperty(name="Material IFC ID")
should_auto_assign_usage: bpy.props.BoolProperty(
name="Auto Assign Usage",
default=True,
assign_as_usage: bpy.props.BoolProperty(
name="Assign Material As A Usage",
default=False,
options={"SKIP_SAVE"},
)
@@ -231,19 +230,25 @@ class AssignMaterialToSelected(bpy.types.Operator, tool.Ifc.Operator):
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.alt:
self.should_auto_assign_usage = False
material_class = tool.Ifc.get().by_id(self.material).is_a()
if material_class not in ("IfcMaterialProfileSet", "IfcMaterialLayerSet"):
self.report({"ERROR"}, f"{material_class} cannot be assigned as a usage.")
return {"CANCELLED"}
self.assign_as_usage = True
return self.execute(context)
def _execute(self, context):
material = tool.Ifc.get().by_id(self.material)
objects = tool.Blender.get_selected_objects()
material_type = material.is_a()
if self.assign_as_usage:
material_type += "Usage"
core.assign_material(
tool.Ifc,
tool.Material,
material_type=material.is_a(),
material_type=material_type,
objects=objects,
material=material,
should_auto_assign_usage=self.should_auto_assign_usage,
)
@@ -609,25 +614,29 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
attributes=attributes,
)
layer_sets_to_regenerate = set()
slab_planer = slab.DumbSlabPlaner()
wall_objs = []
for obj in objects:
obj_element = tool.Ifc.get_entity(obj)
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
if obj_material_usage and obj_material_usage.is_a("IfcMaterialLayerSetUsage"):
obj_material_usage.OffsetFromReferenceLine = material.OffsetFromReferenceLine
obj_material_usage.DirectionSense = material.DirectionSense
obj_material_usage.ReferenceExtent = material.ReferenceExtent
layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet)
# Save custom offset to BBIM_MaterialLayer pset
tool.Model.save_custom_offset_to_pset(obj_element, obj)
for layer_set in layer_sets_to_regenerate:
wall.DumbWallPlaner().regenerate_from_layer_set(layer_set)
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
# Targeted regeneration: only update this element's geometry, not
# all elements sharing the layer set (which would corrupt unrelated instances).
if obj_material_usage.LayerSetDirection == "AXIS3":
slab_planer.regenerate_from_occurence(obj_element, obj_material_usage)
elif obj_material_usage.LayerSetDirection == "AXIS2":
wall_objs.append(obj)
if wall_objs:
tool.Model.recalculate_walls(wall_objs)
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
if "CardinalPoint" in attributes:
@@ -717,11 +726,7 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
self.props.material_set_item_material = str(material_set_item.Material.id())
self.props.material_set_item_attributes.clear()
bonsai.bim.helper.import_attributes(
material_set_item,
self.props.material_set_item_attributes,
callback=self.import_attributes_callback,
)
bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes)
if material_set_item.is_a("IfcMaterialProfile"):
if material_set_item.Profile and material_set_item.Profile.ProfileName:
@@ -729,29 +734,6 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
return {"FINISHED"}
def import_attributes_callback(
self, name: str, prop: Union["Attribute", None], data: dict[str, Any]
) -> None | Literal[True]:
if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop:
return None
# Keep null semantics unchanged on export, but avoid an empty UI selection.
prop.data_type = "enum"
prop.special_type = "LOGICAL"
prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN"))
value = data[name]
if value == "UNKNOWN":
prop.enum_value = "UNKNOWN"
elif value is None:
# Keep visible default as FALSE, but preserve null semantics on save.
prop.enum_value = "FALSE"
prop.is_null = True
else:
prop.enum_value = "TRUE" if value else "FALSE"
return True
class DisableEditingMaterialSetItem(bpy.types.Operator):
bl_idname = "bim.disable_editing_material_set_item"
+6 -11
View File
@@ -118,17 +118,12 @@ class BIM_PT_materials(Panel):
row.operator("bim.edit_material", text="Save Material", icon="CHECKMARK").material = ifc_definition_id
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
elif self.props.editing_material_type == "STYLE":
if MaterialsData.data["styles"]:
row = self.layout.row(align=True)
row.prop(self.props, "contexts", text="")
prop_with_search(row, self.props, "styles", text="")
row = self.layout.row(align=True)
row.operator("bim.edit_material_style", text="Assign Style", icon="CHECKMARK")
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
else:
row = self.layout.row(align=True)
row.label(text="No Styles Found")
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
row = self.layout.row(align=True)
row.prop(self.props, "contexts", text="")
prop_with_search(row, self.props, "styles", text="")
row = self.layout.row(align=True)
row.operator("bim.edit_material_style", text="Assign Style", icon="CHECKMARK")
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
class BIM_PT_object_material(Panel):
+1 -1
View File
@@ -82,7 +82,7 @@ def add_object(self: "BIM_OT_add_object", context: bpy.types.Context) -> None:
class BIM_OT_add_object(Operator, tool.Ifc.Operator):
bl_idname = "bim.add_grid"
bl_idname = "mesh.add_grid"
bl_label = "Grid"
bl_description = "Add IfcGrid."
bl_options = {"REGISTER", "UNDO"}
+2 -2
View File
@@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001)
is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001)
if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]):
if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23):
fitting_type = "WYE"
if not fitting_type:
@@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
radius: bpy.props.FloatProperty(
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
"Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
)
def _execute(self, context):
+138 -29
View File
@@ -151,11 +151,29 @@ class FilledOpeningGenerator:
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
assert representation
representation = ifcopenshell.util.representation.resolve_representation(representation)
else:
representation = self.generate_opening_from_filling(
filling, filling_obj, opening_thickness_si=opening_thickness_si
)
# Check if mapped representation - PRESERVE the mapping structure
if (
representation.RepresentationType == "MappedRepresentation"
and len(representation.Items) == 1
and representation.Items[0].is_a("IfcMappedItem")
):
# Store the existing RepresentationMap to reuse it
existing_mapping_source = representation.Items[0].MappingSource
reuse_mapped_representation = True
else:
representation = ifcopenshell.util.representation.resolve_representation(representation)
if not reuse_mapped_representation:
# Check for library template before generating from filling
template_rep = self.get_opening_template_from_type(filling)
if template_rep:
representation = template_rep
else:
representation = self.generate_opening_from_filling(
filling, filling_obj, opening_thickness_si=opening_thickness_si
)
# Create mapped representation
if reuse_mapped_representation:
@@ -229,38 +247,109 @@ class FilledOpeningGenerator:
voided_element = opening.VoidsElements[0].RelatingBuildingElement
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
# ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.)
preserved_representation = None
if opening_rep:
if (
opening_rep.RepresentationType == "MappedRepresentation"
and len(opening_rep.Items) == 1
and opening_rep.Items[0].is_a("IfcMappedItem")
):
# For mapped representations, copy the underlying representation
preserved_representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
opening_rep.Items[0].MappingSource.MappedRepresentation,
exclude=["IfcGeometricRepresentationContext"],
)
else:
# For direct representations (non-mapped), copy them too
preserved_representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), opening_rep, exclude=["IfcGeometricRepresentationContext"]
)
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling)
# Priority order for choosing representation:
# 1. Existing occurrence with MappedRepresentation (preserve mapping!)
# 2. Library template with Tessellation
# 3. Preserved representation from old opening (maintain user's work)
# 4. Generate from filling (last resort)
representation_to_use = None
reuse_mapped_representation = False
existing_mapping_source = None
if existing_opening_occurrence:
representation = ifcopenshell.util.representation.get_representation(
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
representation = ifcopenshell.util.representation.resolve_representation(representation)
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
)
else:
if (
representation
and representation.RepresentationType == "MappedRepresentation"
and len(representation.Items) == 1
and representation.Items[0].is_a("IfcMappedItem")
):
# PRESERVE the mapped structure - reuse the same RepresentationMap
existing_mapping_source = representation.Items[0].MappingSource
reuse_mapped_representation = True
else:
representation_to_use = ifcopenshell.util.representation.resolve_representation(representation)
if not representation_to_use and not reuse_mapped_representation:
template_rep = self.get_opening_template_from_type(filling)
if template_rep and template_rep.RepresentationType == "Tessellation":
representation_to_use = template_rep
if not representation_to_use and not reuse_mapped_representation and preserved_representation:
representation_to_use = preserved_representation
if not representation_to_use and not reuse_mapped_representation:
opening_obj = tool.Ifc.get_object(opening)
if opening_obj:
tool.Ifc.unlink(element=opening)
tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True)
filling_obj = tool.Ifc.get_object(filling)
representation = self.generate_opening_from_filling(filling, filling_obj)
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation
representation_to_use = self.generate_opening_from_filling(filling, filling_obj)
# Create the mapped representation
if reuse_mapped_representation:
# Reuse existing RepresentationMap - don't create a new one!
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
new_mapped_item = tool.Ifc.get().create_entity(
"IfcMappedItem",
MappingSource=existing_mapping_source,
MappingTarget=tool.Ifc.get().create_entity(
"IfcCartesianTransformationOperator3D",
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)),
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
Scale=1.0,
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
),
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
mapped_representation = tool.Ifc.get().create_entity(
"IfcShapeRepresentation",
ContextOfItems=context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=[new_mapped_item],
)
else:
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation_to_use
)
# update voided object representation or all it's parts if it's an aggregate
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
)
# update voided object representation...
voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element]
for voided_element in voided_elements:
voided_obj = tool.Ifc.get_object(voided_element)
@@ -274,6 +363,36 @@ class FilledOpeningGenerator:
representation=representation,
)
def get_opening_template_from_type(
self, filling: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""
Check if the filling's type has a stored opening template from library import.
"""
element_type = ifcopenshell.util.element.get_type(filling)
if not element_type:
return None
desc = element_type.Description
if not desc or "||BonsaiOpeningTemplate:" not in desc:
return None
# Extract template ID
marker = desc.split("||BonsaiOpeningTemplate:")[-1]
template_id = int(marker.split("||")[0])
try:
template_rep = tool.Ifc.get().by_id(template_id)
# Make a copy so we don't reuse the same representation instance
copied = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), template_rep, exclude=["IfcGeometricRepresentationContext"]
)
return copied
except:
return None
def generate_opening_from_filling(
self,
filling: ifcopenshell.entity_instance,
@@ -540,16 +659,6 @@ class AddBoolean(Operator, tool.Ifc.Operator):
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
rep_obj = tool.Geometry.get_geometry_props().representation_obj
if booleans:
# Users typically select two top-level items and expect the
# operand to be absorbed into the boolean, not remain as a
# standalone item alongside it.
representation = tool.Geometry.get_active_representation(rep_obj)
representation = ifcopenshell.util.representation.resolve_representation(representation)
second_items_set = set(second_items)
new_items = [i for i in representation.Items if i not in second_items_set]
if new_items:
representation.Items = new_items
rep_element = tool.Ifc.get_entity(rep_obj)
tool.Model.mark_manual_booleans(rep_element, booleans)
tool.Geometry.reload_representation(rep_obj)
@@ -421,7 +421,7 @@ class PolylineOperator:
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
return {"RUNNING_MODAL"}
return {"RUNNING_MODAL"}
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
props = tool.Model.get_model_props()
@@ -461,7 +461,6 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
tool.Raycast.clear_snap_objs()
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
@@ -694,14 +694,10 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[
new_settings = settings.copy()
new_settings["context"] = box_context
new_box = ifcopenshell.api.geometry.add_representation(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
**new_settings,
)
new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings)
ifcopenshell.api.geometry.assign_representation(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
should_run_listeners=False,
product=product,
representation=new_box,
)
+16 -40
View File
@@ -18,7 +18,7 @@
import copy
from math import atan2, degrees, pi, radians
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
from typing import Any, Literal, Optional, Union
import bpy
import ifcopenshell
@@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None]
class DumbProfileGenerator:
def __init__(self, relating_type: ifcopenshell.entity_instance):
def __init__(self, relating_type):
self.relating_type = relating_type
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -201,7 +201,7 @@ class DumbProfileGenerator:
class DumbProfileRegenerator:
def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None:
def regenerate_from_profile_def(self, profile):
self.file = tool.Ifc.get()
objs = []
if not profile:
@@ -221,7 +221,7 @@ class DumbProfileRegenerator:
for element in self.get_element_types_using_profile(profile):
tool.Model.mark_thumbnail_for_update(element)
def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
def regenerate_from_profile(self, usecase_path, ifc_file, settings):
self.file = ifc_file
objs = []
profile = settings["profile"].Profile
@@ -233,7 +233,7 @@ class DumbProfileRegenerator:
objs.append(obj)
DumbProfileRecalculator().recalculate(objs)
def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
def get_elements_using_profile(self, profile):
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -252,9 +252,7 @@ class DumbProfileRegenerator:
results.extend(rel.RelatedObjects)
return results
def get_element_types_using_profile(
self, profile: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
def get_element_types_using_profile(self, profile):
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -271,18 +269,12 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_profile"
bl_label = "Extend Profile"
bl_options = {"REGISTER", "UNDO"}
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
default="-",
)
if TYPE_CHECKING:
join_type: Literal["-", "L", "V", "T"]
join_type: bpy.props.StringProperty()
def _execute(self, context):
selected_objs = context.selected_objects
joiner = DumbProfileJoiner()
if self.join_type == "-":
if not self.join_type:
for obj in selected_objs:
joiner.unjoin(obj)
return {"FINISHED"}
@@ -634,15 +626,11 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[1] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -685,15 +673,11 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[0] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -737,9 +721,7 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[1] = intersect
else:
plane = self.get_profile_plane(
@@ -747,9 +729,7 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
@@ -762,9 +742,7 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[0] = intersect
else:
plane = self.get_profile_plane(
@@ -772,9 +750,7 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
+13 -3
View File
@@ -277,8 +277,19 @@ class DumbSlabPlaner:
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
offset_direction = direction_ratios.copy()
perpendicular_depth = thickness * abs(1 / cos(existing_x_angle))
perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale
# The extrusion depth needed to achieve a given perpendicular thickness depends on
# how much the extrusion direction deviates from the slab face normal (local Z).
# For an ObjectPlacement-rotated slab, extrusion_vec.z ≈ 1.0 → no scaling.
# For an ExtrudedDirection-tilted slab, extrusion_vec.z < 1.0 → scale up.
extrusion_z = abs(direction_ratios.normalized().z)
if extrusion_z > 1e-6:
perpendicular_depth = thickness / extrusion_z
perpendicular_offset = layer_offset / extrusion_z / self.unit_scale
else:
perpendicular_depth = thickness
perpendicular_offset = layer_offset / self.unit_scale
ifc_position = extrusion.Position
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or (
@@ -301,7 +312,6 @@ class DumbSlabPlaner:
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
ifc_position = extrusion.Position
position = offset_direction * perpendicular_offset
material = ifcopenshell.util.element.get_material(element)
if material:
+2 -2
View File
@@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings):
return
task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask"))
qto = ifcopenshell.api.pset.add_qto(
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument]
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities"
)
ifcopenshell.api.pset.edit_qto(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
should_run_listeners=False,
qto=qto,
properties={
"StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days,
@@ -1268,6 +1268,27 @@ class DumbWallJoiner:
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2)
return wall2
def join_Z(self, wall1, slab2):
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(slab2)
for rel in element1.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP":
ifcopenshell.api.geometry.disconnect_element(
tool.Ifc.get(),
relating_element=rel.RelatingElement,
related_element=element1,
)
ifcopenshell.api.geometry.connect_element(
tool.Ifc.get(),
relating_element=element2,
related_element=element1,
description="TOP",
)
tool.Model.recreate_wall(element1, wall1)
def set_axis(self, wall, p1, p2):
axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
@@ -1312,6 +1333,29 @@ class DumbWallJoiner:
self.set_axis(element1, p1, p2)
tool.Model.recreate_wall(element1, wall1)
def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None:
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(wall2)
axis1 = tool.Model.get_wall_axis(wall1)
axis2 = tool.Model.get_wall_axis(wall2)
intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"])
if intersect:
intersect, _ = intersect
else:
return
connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART"
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
related_element=element1,
relating_element=element2,
relating_connection="ATPATH",
related_connection=connection,
description="BUTT",
)
tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"])
def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None:
wall1 = tool.Ifc.get_entity(obj1)
wall2 = tool.Ifc.get_entity(obj2)
@@ -943,7 +943,7 @@ class EditObjectUI:
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
@@ -101,7 +101,7 @@ class NestDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -76,7 +76,6 @@ classes = (
operator.UnlinkIfc,
operator.UnloadLink,
workspace.ExploreHotkey,
operator.GenerateUVMap,
prop.LibraryBreadcrumb,
prop.LibraryElement,
prop.FilterCategory,
+99 -198
View File
@@ -178,18 +178,9 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_description = (
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
)
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
filter_glob: str
append_all: bool
use_relative_path: bool
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
append_all: bpy.props.BoolProperty(default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
reload_previous_file = False
@@ -567,11 +558,7 @@ class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.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") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
query: str
query: bpy.props.StringProperty(name="Query")
@classmethod
def poll(cls, context):
@@ -600,16 +587,9 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
"Append element to the current project.\n\n"
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
)
definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
assume_unique_by_name: bpy.props.BoolProperty(
name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
definition: int
prop_index: int
assume_unique_by_name: bool
definition: bpy.props.IntProperty()
prop_index: bpy.props.IntProperty()
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
file: ifcopenshell.file
@@ -638,6 +618,8 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"FINISHED"}
if element.is_a("IfcTypeProduct"):
# Store opening template from library if it exists
self.store_opening_template_from_library(element, library_file)
self.import_type_from_ifc(element, context)
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
@@ -738,6 +720,53 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()):
ifc_importer.create_style(element)
def store_opening_template_from_library(
self, element: ifcopenshell.entity_instance, library_file: ifcopenshell.file
) -> None:
"""
Find an opening representation in the library and copy it to the current file
as a template. Store the template ID on the type for later retrieval.
"""
try:
library_element = library_file.by_guid(element.GlobalId)
except:
return
# Find occurrences with openings in the library
library_occurrences = ifcopenshell.util.element.get_types(library_element)
for occurrence in library_occurrences:
if not getattr(occurrence, "FillsVoids", None):
continue
library_opening = occurrence.FillsVoids[0].RelatingOpeningElement
library_opening_rep = ifcopenshell.util.representation.get_representation(
library_opening, "Model", "Body", "MODEL_VIEW"
)
if not library_opening_rep:
continue
# Check if mapped representation
if (
library_opening_rep.RepresentationType == "MappedRepresentation"
and len(library_opening_rep.Items) == 1
and library_opening_rep.Items[0].is_a("IfcMappedItem")
):
mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation
# Store ALL representation types (Tessellation, SweptSolid, etc.)
template_rep = ifcopenshell.util.element.copy_deep(
self.file, mapped_rep, exclude=["IfcGeometricRepresentationContext"]
)
# Store reference in type's Description
current_desc = element.Description or ""
element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}"
return
break
class EditProjectLibrary(bpy.types.Operator):
bl_idname = "bim.edit_project_library"
@@ -959,28 +988,24 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_label = "Load Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load an existing IFC project"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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",
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( # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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."
@@ -988,20 +1013,9 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
use_detailed_tooltip: bpy.props.BoolProperty(
default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
if TYPE_CHECKING:
filepath: str
filter_glob: str
is_advanced: bool
use_relative_path: bool
should_start_fresh_session: bool
import_without_ifc_data: bool
use_detailed_tooltip: bool
@classmethod
def description(cls, context, properties):
tooltip = cls.bl_description
@@ -1300,10 +1314,7 @@ class ToggleFilterCategories(bpy.types.Operator):
bl_idname = "bim.toggle_filter_categories"
bl_label = "Toggle Filter Categories"
bl_options = {"REGISTER", "UNDO"}
should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
should_select: bool
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
def execute(self, context):
props = tool.Project.get_project_props()
@@ -1327,14 +1338,6 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
default=False,
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
filename_ext = ".ifc"
if TYPE_CHECKING:
@@ -1344,25 +1347,20 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
filter_glob: str
use_relative_path: bool
use_cache: bool
query: str
def draw(self, context):
assert self.layout
pprops = tool.Project.get_project_props()
row = self.layout.row()
row.prop(self, "use_relative_path")
row = self.layout.row()
row.prop(self, "use_cache")
row = self.layout.row()
row.label(text="False Origin Mode:")
row = self.layout.row()
row.prop(pprops, "false_origin_mode", text="")
row.prop(pprops, "false_origin_mode")
if pprops.false_origin_mode == "MANUAL":
row = self.layout.row()
row.prop(pprops, "false_origin")
row = self.layout.row()
row.prop(pprops, "project_north")
self.layout.prop(self, "query", placeholder="IfcElement")
def _execute(self, context):
start = time.time()
@@ -1395,7 +1393,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
@@ -1403,11 +1401,7 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
link_index: bpy.props.IntProperty(name="Link Index")
def _execute(self, context):
props = tool.Project.get_project_props()
@@ -1427,11 +1421,7 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
link_index: bpy.props.IntProperty(name="Link Index")
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
@@ -1456,12 +1446,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
use_cache: bool
query: str
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
@@ -1503,20 +1491,8 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool:
if not self.use_cache:
return True
if not blend_filepath.exists():
return False
data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
query = data.get("query", "")
return query != self.query
if should_clear_cache():
if not self.use_cache and blend_filepath.exists():
os.remove(blend_filepath)
if not blend_filepath.exists():
@@ -1544,7 +1520,7 @@ def run():
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)})
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
@@ -1630,11 +1606,7 @@ class ReloadLink(bpy.types.Operator):
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
bpy.ops.bim.unload_link(link_index=self.link_index)
@@ -1646,11 +1618,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
@@ -1820,11 +1788,7 @@ class SelectLinkHandle(bpy.types.Operator):
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
@@ -1882,28 +1846,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
filter_glob: bpy.props.StringProperty(
default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
json_version: bpy.props.EnumProperty(
items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version"
) # pyright: ignore[reportRedeclaration]
json_compact: bpy.props.BoolProperty(
name="Export Compact IFCJSON", default=False
) # pyright: ignore[reportRedeclaration]
should_save_as: bpy.props.BoolProperty(
name="Should Save As", default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
filter_glob: str
json_version: str
json_compact: bool
should_save_as: bool
use_relative_path: bool
filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
@classmethod
def poll(cls, context):
@@ -2053,12 +2000,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
bl_options = {"REGISTER", "UNDO"}
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
query: str
file: ifcopenshell.file
meshes: dict[str, bpy.types.Mesh]
# Material names is derived from diffuse as in 'r-g-b-a'.
@@ -2108,17 +2049,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
tool.Loader.settings.context_settings = tool.Loader.create_settings()
tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True)
if self.query:
self.elements = ifcopenshell.util.selector.filter_elements(self.file, self.query)
self.elements = set(self.file.by_type("IfcElement"))
if self.file.schema in ("IFC2X3", "IFC4"):
self.elements |= set(self.file.by_type("IfcProxy"))
if self.file.schema == "IFC2X3":
self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
else:
self.elements = set(self.file.by_type("IfcElement"))
if self.file.schema in ("IFC2X3", "IFC4"):
self.elements |= set(self.file.by_type("IfcProxy"))
if self.file.schema == "IFC2X3":
self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
else:
self.elements |= set(self.file.by_type("IfcSpatialElement"))
self.elements -= set(self.file.by_type("IfcFeatureElement"))
self.elements |= set(self.file.by_type("IfcSpatialElement"))
self.elements -= set(self.file.by_type("IfcFeatureElement"))
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
@@ -2143,7 +2081,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
"false_origin_mode": pprops.false_origin_mode,
"false_origin": pprops.false_origin,
"project_north": pprops.project_north,
"query": self.query,
}
with open(self.json_filepath, "w") as f:
json.dump(data, f)
@@ -2558,7 +2495,7 @@ class EnableCulling(bpy.types.Operator):
self.total_mousemoves = 0
self.cullable_objects = []
def modal(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
def modal(self, context, event):
if not LinksData.enable_culling:
for obj in bpy.context.visible_objects:
if obj.type == "MESH" and obj.name.startswith("Ifc"):
@@ -2589,7 +2526,7 @@ class EnableCulling(bpy.types.Operator):
return {"PASS_THROUGH"}
def is_view_changed(self, context: bpy.types.Context) -> bool:
def is_view_changed(self, context):
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
vp_matrix = projection_matrix @ view_matrix
@@ -2604,7 +2541,7 @@ class EnableCulling(bpy.types.Operator):
return True
return False
def is_object_in_view(self, obj: bpy.types.Object, context: bpy.types.Context, camera_position: Vector) -> bool:
def is_object_in_view(self, obj, context, camera_position):
# Get the view matrix and the projection matrix from the active viewport
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
@@ -2631,7 +2568,7 @@ class EnableCulling(bpy.types.Operator):
return False
return True
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set["rna_enums.OperatorReturnItems"]:
def invoke(self, context, event):
LinksData.enable_culling = True
self.cullable_objects = []
for obj in bpy.context.visible_objects:
@@ -2918,16 +2855,8 @@ class IFCFileHandlerOperator(bpy.types.Operator):
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
directory: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
files: bpy.props.CollectionProperty(
type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
directory: str
files: list[bpy.types.OperatorFileListElement]
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
def invoke(self, context, event):
# Keeping code in .invoke() as we'll probably add some
@@ -2978,10 +2907,7 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
measure_type: str
measure_type: bpy.props.StringProperty()
@classmethod
def poll(cls, context):
@@ -3077,10 +3003,7 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Face Area Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
measure_type: str
measure_type: bpy.props.StringProperty()
@classmethod
def poll(cls, context):
@@ -3184,10 +3107,7 @@ class ClearMeasurement(bpy.types.Operator):
@classmethod
def poll(cls, context):
polyline_props = tool.Model.get_polyline_props()
if len(polyline_props.measurement_polyline) > 0:
return True
cls.poll_message_set("No measurement to clear.")
return False
return len(polyline_props.measurement_polyline) > 0
def execute(self, context):
polyline_props = tool.Model.get_polyline_props()
@@ -3291,7 +3211,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
super().invoke(context, event)
return {"RUNNING_MODAL"}
def cancel_tool(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
def cancel_tool(self, context):
context.workspace.status_text_set(text=None)
if hasattr(self, "tool_state"):
self.tool_state.plane_method = None
@@ -3299,7 +3219,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
tool.Blender.update_viewport()
return {"CANCELLED"}
def handle_custom_instructions(self, context: bpy.types.Context) -> None:
def handle_custom_instructions(self, context):
if len(self.selected_points) == 0:
instruction_text = "Click First Point on Image"
elif len(self.selected_points) == 1:
@@ -3314,14 +3234,14 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
context.workspace.status_text_set(text=instruction_text)
def calculate_distance(self) -> None:
def calculate_distance(self):
if len(self.selected_points) == 2:
point1 = self.selected_points[0]
point2 = self.selected_points[1]
distance_3d = (point2 - point1).length
self.calculated_distance = distance_3d / self.unit_scale
def apply_scaling(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
def apply_scaling(self, context):
if len(self.selected_points) != 2:
self.report({"ERROR"}, "Two points must be selected")
return {"CANCELLED"}
@@ -3379,10 +3299,7 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
bl_options = {"REGISTER", "UNDO"}
filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
filepath: str
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
def execute(self, context):
ifc_file = self.filepath
@@ -3416,19 +3333,3 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bpy.app.handlers.load_post.append(load_handler)
bpy.ops.wm.open_mainfile(filepath=metadata_path)
return {"FINISHED"}
class GenerateUVMap(bpy.types.Operator):
bl_idname = "bim.generate_uv_map"
bl_label = "Generate UV Map"
bl_description = "Generate UV map for selected mesh."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
obj = context.active_object
if not obj or not isinstance(obj.data, bpy.types.Mesh):
self.report({"ERROR"}, "No valid mesh selected.")
return {"CANCELLED"}
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}
+1 -1
View File
@@ -496,7 +496,7 @@ class BIM_PT_links(Panel):
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
else:
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
if LinksData.enable_culling:
@@ -71,26 +71,21 @@ class ExploreTool(bpy.types.WorkSpaceTool):
row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT")
row.label(text="", icon="EVENT_M")
row = layout.row(align=True)
op = row.operator("bim.explore_hotkey", text="Measure Tool", icon="CON_DISTLIMIT")
op.hotkey = "S_M"
row = layout.row(align=True)
row.prop(prop, "measurement_type", text="Measure Type", expand=True, icon_only=True, emboss=True)
row = layout.row(align=True)
op = row.operator("bim.clear_measurement", text="", icon="X")
row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT")
row.label(text="", icon="EVENT_S")
row = layout.row(align=True)
op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE")
op.hotkey = "S_S"
op.description = (
"Scale Image Annotation.\n\n"
"Allows to scale an IfcReferenceImage.\n\n"
"Select image, select tool. "
"Check lower left corner instructions to select two points and provide real distance between them"
)
row = layout.row(align=True)
row.operator("bim.generate_uv_map", icon="UV")
op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them"
class ExploreHotkey(bpy.types.Operator):
+1 -1
View File
@@ -262,7 +262,7 @@ class BIM_PT_object_psets(Panel):
row = self.layout.row(align=True)
prop_with_search(row, props, "pset_name", text="")
if props.pset_name != "BBIM_BSDD" and not props.pset_name.startswith(tool.Bsdd.identifier_url()):
if props.pset_name != "BBIM_BSDD" and not props.pset_name.startswith(tool.Bsdd.identifier_url):
op = row.operator("bim.add_pset", icon="ADD", text="")
op.obj = obj.name
op.obj_type = "Object"
@@ -321,7 +321,7 @@ def get_gross_perimeter(o: bpy.types.Object) -> float:
return gross_perimeter
def get_space_net_perimeter(obj: bpy.types.Object) -> None:
def get_space_net_perimeter(obj: bpy.types.Object) -> float:
pass
@@ -619,7 +619,7 @@ class SelectFilterElements(bpy.types.Operator):
return {"FINISHED"}
class ApplyFilterFromText(Operator):
class ApplyFilterFromText(Operator, tool.Ifc.Operator):
bl_idname = "bim.apply_filter_from_text"
bl_label = "Apply Filter Configuration"
bl_description = "Apply the JSON filter configuration from the current text block"
@@ -1440,7 +1440,7 @@ class ShowAllElements(Operator):
return {"FINISHED"}
class SelectSimilar(Operator):
class SelectSimilar(Operator, tool.Ifc.Operator):
bl_idname = "bim.select_similar"
bl_label = "Select Similar"
bl_options = {"REGISTER", "UNDO"}
+1 -1
View File
@@ -251,7 +251,7 @@ class BIM_PT_grids(Panel):
bl_options = {"HEADER_LAYOUT_EXPAND"}
def draw(self, context):
self.layout.row().operator("bim.add_grid", icon="ADD", text="Add Grids")
self.layout.row().operator("mesh.add_grid", icon="ADD", text="Add Grids")
def draw_header(self, context):
props = tool.Spatial.get_grid_props()
@@ -28,7 +28,6 @@ import ifcopenshell.util.representation
import ifcopenshell.util.unit
import ifcopenshell.util.unit as ifcunit
import numpy as np
import numpy.typing as npt
from mathutils import Vector
import bonsai.tool as tool
@@ -479,7 +478,7 @@ class ShaderInfo:
"""get the args to the point shader"""
location = np.array(location)
indices = []
direction_dict: dict[str, tuple[npt.NDArray, ...]] = {
direction_dict = {
"fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))),
"fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))),
"fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))),
@@ -83,7 +83,7 @@ class DecorationShader:
PARALLEL DISTRIBUTED FORCE,
DISTRIBUTED MOMENT,
"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "forces")
vert_out.smooth("VEC3", "co")
@@ -203,7 +203,7 @@ class DecorationShader:
"""param: pattern: type of pattern
SINGLE FORCE,
SINGLE MOMENT"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -253,7 +253,7 @@ class DecorationShader:
def get_planar_shader(self) -> gpu.types.GPUShader:
"""shader for planar loads"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
+2
View File
@@ -787,6 +787,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
doc: DocPreferences
default_parameters: DefaultParameters
container_hide_show_isolate: bool
mass_time_units_in_wizard: bool
chain_filter_with_set_operations: bool
save_metadata_blend_file: bool
metadata_blend_file_suffix: str
@@ -985,6 +986,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
layout.prop(self, "mass_time_units_in_wizard")
row = layout.row(align=True)
row.prop(self, "chain_filter_with_set_operations")
row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270"
+61 -11
View File
@@ -51,11 +51,21 @@ def add_instance_flooring_covering_from_cursor(
if isinstance(space_polygon, str):
return
obj = spatial.create_object("Covering")
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
name = "Covering"
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
obj = spatial.get_named_obj_from_mesh(name, mesh)
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
def add_instance_ceiling_covering_from_cursor(
@@ -85,11 +95,21 @@ def add_instance_ceiling_covering_from_cursor(
if isinstance(space_polygon, str):
return
obj = spatial.create_object("Covering")
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
name = "Covering"
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
obj = spatial.get_named_obj_from_mesh(name, mesh)
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None:
@@ -107,7 +127,19 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
if isinstance(space_polygon, str):
return
spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True)
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
name = "Aux"
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
obj = spatial.get_named_obj_from_mesh(name, mesh)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points)
body = spatial.get_body_representation(active_obj)
spatial.regen_obj_representation(active_obj, body)
# TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS
@@ -119,13 +151,22 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
spatial.set_obj_origin_to_bboxcenter(obj)
spatial.translate_obj_to_z_location(obj, z)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
def add_instance_ceiling_coverings_from_walls(
@@ -138,13 +179,22 @@ def add_instance_ceiling_coverings_from_walls(
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
spatial.set_obj_origin_to_bboxcenter(obj)
spatial.translate_obj_to_z_location(obj, z)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
class NoDefaultContainer(Exception):
+4 -1
View File
@@ -432,7 +432,10 @@ def update_drawing_name(
if drawing_tool.get_name(drawing) != name:
ifc.run("attribute.edit_attributes", product=drawing, attributes={"Name": name})
drawing_tool.set_camera_name(drawing, name)
# Update the camera object name
camera = ifc.get_object(drawing)
if camera and camera.name != name:
camera.name = name
group = drawing_tool.get_drawing_group(drawing)
if drawing_tool.get_name(group) != name:
+2 -11
View File
@@ -113,7 +113,6 @@ def assign_material(
material_type: Union[str, None],
objects: list[bpy.types.Object],
material: Optional[ifcopenshell.entity_instance] = None,
should_auto_assign_usage: bool = True,
) -> None:
"""Assign material to the provided objects.
@@ -122,18 +121,12 @@ def assign_material(
"""
material_type = material_type or material_tool.get_object_ui_material_type()
material = material or material_tool.get_object_ui_active_material()
can_be_usage = should_auto_assign_usage and material_type in ("IfcMaterialLayerSet", "IfcMaterialProfileSet")
for obj in objects:
element = ifc.get_entity(obj)
if not element:
continue
if can_be_usage and not material_tool.is_type_product(element):
element_material_type = material_type + "Usage"
else:
element_material_type = material_type
ifc.run("material.assign_material", products=[element], type=element_material_type, material=material)
ifc.run("material.assign_material", products=[element], type=material_type, material=material)
assigned_material = material_tool.get_material(element)
assert assigned_material # Type checker.
@@ -143,9 +136,7 @@ def assign_material(
material_tool.add_material_to_set(material_set=material, material=default_material)
elif material_tool.is_a_material_set(assigned_material):
material_tool.add_material_to_set(material_set=assigned_material, material=material)
material_tool.ensure_material_assigned(
elements=[element], material_type=element_material_type, material=material
)
material_tool.ensure_material_assigned(elements=[element], material_type=material_type, material=material)
def unassign_material(ifc: type[tool.Ifc], material_tool: type[tool.Material], objects: list[bpy.types.Object]) -> None:
+43 -1
View File
@@ -109,7 +109,7 @@ def align_walls(
align_type: AlignType,
):
reference_obj = blender.get_active_object(is_selected=True)
if not reference_obj or not (e := ifc.get_entity(reference_obj)) or not model.get_usage_type(e) == "LAYER2":
if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"):
reference_obj = None
objs = [
o
@@ -159,6 +159,48 @@ def extend_wall_to_slab(
model.reload_body_representation(wall_objs)
def join_walls_TZ(
ifc: type[tool.Ifc],
blender: type[tool.Blender],
geometry: type[tool.Geometry],
joiner: DumbWallJoiner,
model: type[tool.Model],
) -> None:
selected_objs = [
o
for o in blender.get_selected_objects()
if (e := ifc.get_entity(o)) and model.get_usage_type(e) in ("LAYER2", "LAYER3")
]
if len(selected_objs) < 2:
raise RequireAtLeastTwoLayeredElements(
"Two or more vertically or horizontally layered elements must be selected to connect their paths together"
)
for obj in selected_objs:
geometry.clear_scale(obj)
elements = [ifc.get_entity(o) for o in blender.get_selected_objects()]
layer2_elements = []
layer3_elements = []
for element in elements:
usage = model.get_usage_type(element)
if usage == "LAYER2":
layer2_elements.append(element)
elif usage == "LAYER3":
layer3_elements.append(element)
if layer3_elements:
target = ifc.get_object(layer3_elements[0])
for element in layer2_elements:
joiner.join_Z(ifc.get_object(element), target)
else:
if not (active_obj := blender.get_active_object()):
active_obj = selected_objs[0]
for obj in selected_objs:
if obj == active_obj:
continue
joiner.join_T(obj, active_obj)
class RequireTwoWallsError(Exception):
pass
+12 -8
View File
@@ -219,8 +219,13 @@ def generate_space(
else:
assert space_polygon
bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True)
mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm)
if element and element.is_a("IfcSpace"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
spatial.edit_active_space_obj_from_mesh(mesh)
spatial.translate_obj_to_z_location(active_obj, z)
else:
if relating_type:
@@ -228,13 +233,12 @@ def generate_space(
else:
name = "Space"
obj = spatial.create_object(name)
obj = spatial.get_named_obj_from_mesh(name, mesh)
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
@@ -253,16 +257,16 @@ def generate_spaces_from_walls(
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False)
name = "Space" + str(i)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False)
def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None:
model = ifc.get()
+13 -16
View File
@@ -349,10 +349,7 @@ class Drawing:
def enable_editing_text(cls, obj): pass
def ensure_unique_drawing_name(cls, name): pass
def ensure_unique_identification(cls, identification): pass
def export_font_size(cls, obj): pass
def export_symbol(cls, obj): pass
def export_text_literal_attributes(cls, obj): pass
def export_wrap_length(cls, obj): pass
def generate_drawing_matrix(cls, target_view, location_hint): pass
def generate_drawing_name(cls, target_view, location_hint): pass
def generate_reference_attributes(cls, reference, **attributes): pass
@@ -405,7 +402,6 @@ class Drawing:
def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass
def run_type_assign_type(cls, element=None, relating_type=None): pass
def select_assigned_product(cls, drawing): pass
def set_camera_name(cls, drawing, name): pass
def set_drawing_collection_name(cls, drawing, collection): pass
def set_name(cls, element, name): pass
def setup_annotation_object(cls, obj, object_type): pass
@@ -584,7 +580,6 @@ class Material:
def import_material_definitions(cls, material_type: str): pass
def is_a_flow_segment(cls, element): pass
def is_a_material_set(cls, material): pass
def is_type_product(cls, element): pass
def is_editing_materials(cls): pass
def is_material_used_in_sets(cls, material): pass
def load_material_attributes(cls, material): pass
@@ -624,10 +619,7 @@ class Model:
def import_rectangle(cls, obj, position, profile): pass
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
def recalculate_walls(cls, objs): pass
def regenerate_array(cls, parent, data): pass
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -999,12 +991,14 @@ class Spatial:
def get_purged_inner_holes_poly(cls, union_geom, min_area): pass
def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass
def get_buffered_poly_from_linear_ring(cls, linear_ring): pass
def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass
def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass
def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass
def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass
def create_object(cls, name): pass
def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass
def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass
def get_named_obj_from_bmesh(cls, name, bmesh): pass
def get_named_obj_from_mesh(cls, name, mesh): pass
def get_named_mesh_from_bmesh(cls, name, bmesh): pass
def get_transformed_mesh_from_local_to_global(cls, mesh): pass
def edit_active_space_obj_from_mesh(cls, mesh): pass
def set_obj_origin_to_bboxcenter(cls, obj): pass
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass
def get_selected_objects(cls): pass
def get_active_obj(cls): pass
@@ -1012,9 +1006,14 @@ class Spatial:
def get_active_obj_height(cls): pass
def get_relating_type_id(cls): pass
def translate_obj_to_z_location(cls, obj, z): pass
def get_2d_vertices_from_obj(cls, obj): pass
def get_scaled_2d_vertices(cls, points): pass
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass
def get_body_representation(cls, obj): pass
def assign_ifcspace_class_to_obj(cls, obj): pass
def assign_type_to_obj(cls, obj): pass
def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass
def regen_obj_representation(cls, obj, body): pass
def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass
def toggle_hide_spaces(cls, spaces): pass
def set_default_container(cls, container): pass
@@ -1119,8 +1118,6 @@ class Type:
def get_representation_context(cls, representation): pass
def get_type_occurrences(cls, element_type): pass
def has_material_usage(cls, element): pass
def record_material_usage_attributes(cls, element): pass
def restore_material_usage_attributes(cls, element, usage_attributes): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
def run_geometry_switch_representation(cls, obj=None, representation=None): pass
+9 -28
View File
@@ -49,40 +49,21 @@ class Aggregate(bonsai.core.tool.Aggregate):
related_object = tool.Ifc.get_entity(related_obj)
if not relating_object or not related_object:
return False
if relating_object == related_object:
return False
is_compatible_class = False
if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a(
"IfcElement"
):
is_compatible_class = True
elif tool.Ifc.get_schema() == "IFC2X3":
return True
if tool.Ifc.get_schema() == "IFC2X3":
if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"):
is_compatible_class = True
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
is_compatible_class = True
return True
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
return True
else:
if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"):
is_compatible_class = True
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
is_compatible_class = True
if not is_compatible_class:
return False
# Prevent cyclic references: walk up the full hierarchy from the
# proposed parent and reject if we encounter the proposed child.
ancestor = ifcopenshell.util.element.get_parent(relating_object)
seen = {relating_object}
while ancestor:
if ancestor == related_object:
return False
if ancestor in seen:
break
seen.add(ancestor)
ancestor = ifcopenshell.util.element.get_parent(ancestor)
return True
return True
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
return True
return False
@classmethod
def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool:
+7 -22
View File
@@ -32,28 +32,14 @@ import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from bsdd.bsdd import ClassContractV1, ClassPropertyContractV1, PropertyContractV5
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDDictionary
class Bsdd(bonsai.core.tool.Bsdd):
default_identifier_url = "https://identifier.buildingsmart.org"
default_api_url = "https://api.bsdd.buildingsmart.org/api/"
identifier_url = "https://identifier.buildingsmart.org"
client = bsdd.Client()
bsdd_classes: dict[str, ClassContractV1] = {}
bsdd_properties: dict[str, ClassPropertyContractV1 | PropertyContractV5] = {}
@classmethod
def identifier_url(cls) -> str:
"""Derives the identifier base URL from the current client baseurl.
Falls back to the standard bSDD identifier URL when using the default API."""
if cls.client.baseurl == cls.default_api_url:
return cls.default_identifier_url
from urllib.parse import urlparse
parsed = urlparse(cls.client.baseurl)
return f"{parsed.scheme}://{parsed.netloc}"
bsdd_classes: dict[str, dict] = {}
bsdd_properties: dict[str, dict] = {}
@classmethod
def get_bsdd_props(cls) -> BIMBSDDProperties:
@@ -269,8 +255,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
@classmethod
def get_bsdd_property(cls, uri: str) -> dict:
if not (bsdd_property := cls.bsdd_properties.get(uri, {})):
# Cache miss occurs for keyword search mode, for classes cache is prepopulated.
bsdd_property = cls.client.get_property(uri)
bsdd_property = cls.client.get_property(uri, include_classes=True)
cls.bsdd_properties[uri] = bsdd_property
return bsdd_property
@@ -284,7 +269,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
for obj in tool.Blender.get_selected_objects(include_active=True):
if element := tool.Ifc.get_entity(obj):
for reference in ifcopenshell.util.classification.get_references(element):
if (uri := reference.Location) and uri.startswith(cls.identifier_url()):
if (uri := reference.Location) and uri.startswith(cls.identifier_url):
classes.add((reference[1] or reference[2] or "Unnamed", uri))
dictionary_uris = (
@@ -398,7 +383,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
def get_applicable_psets(cls, element: ifcopenshell.entity_instance):
uris = set()
for reference in ifcopenshell.util.classification.get_references(element):
if (uri := reference.Location) and uri.startswith(cls.identifier_url()):
if (uri := reference.Location) and uri.startswith(cls.identifier_url):
uris.add(uri)
psets = set()
for uri in uris:
@@ -414,7 +399,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
def is_applicable(cls, pset_uri: str, element: ifcopenshell.entity_instance) -> bool:
uris = set()
for reference in ifcopenshell.util.classification.get_references(element):
if (uri := reference.Location) and uri.startswith(cls.identifier_url()):
if (uri := reference.Location) and uri.startswith(cls.identifier_url):
uris.add(uri)
class_uri, pset_name = pset_uri.rsplit("#", 1)
return class_uri in uris
+1 -1
View File
@@ -154,7 +154,7 @@ class Cost(bonsai.core.tool.Cost):
device = aud.Device()
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__()
sound = aud.Sound(filepath) # ty:ignore[too-many-positional-arguments]
sound = aud.Sound(filepath)
device.play(sound)
@classmethod
+7 -23
View File
@@ -603,10 +603,6 @@ class Drawing(bonsai.core.tool.Drawing):
props = tool.Drawing.get_text_props(obj)
for literal_props in props.literals:
literal_data = bonsai.bim.helper.export_attributes(literal_props.attributes)
alignment = literal_props.align_vertical + "-" + literal_props.align_horizontal
if alignment == "middle-middle":
alignment = "center"
literal_data["BoxAlignment"] = alignment
literals.append(literal_data)
return literals
@@ -857,8 +853,6 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
if not literal_attributes:
return
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
@@ -1182,26 +1176,22 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def import_text_attributes(cls, obj: bpy.types.Object) -> None:
from bonsai.bim.module.drawing.prop import BOX_ALIGNMENT_POSITIONS
props = cls.get_text_props(obj)
props.literals.clear()
ifc_literals = cls.get_text_literal(obj, return_list=True)
assert isinstance(ifc_literals, list)
if ifc_literals:
first_alignment = getattr(ifc_literals[0], "BoxAlignment", None) or "bottom-left"
if first_alignment == "center":
first_alignment = "middle-middle"
props.align_vertical, props.align_horizontal = first_alignment.split("-")
for ifc_literal in ifc_literals:
literal_props = props.literals.add()
bonsai.bim.helper.import_attributes(ifc_literal, literal_props.attributes)
alignment = getattr(ifc_literal, "BoxAlignment", None) or "bottom-left"
if alignment == "center":
alignment = "middle-middle"
literal_props.align_vertical, literal_props.align_horizontal = alignment.split("-")
box_alignment_mask = [False] * 9
position_string = literal_props.attributes["BoxAlignment"].string_value
box_alignment_mask[BOX_ALIGNMENT_POSITIONS.index(position_string)] = True
literal_props.box_alignment = box_alignment_mask # pyright: ignore[reportAttributeAccessIssue]
literal_props.ifc_definition_id = ifc_literal.id()
from bonsai.bim.module.drawing.data import DecoratorData
@@ -1290,12 +1280,6 @@ class Drawing(bonsai.core.tool.Drawing):
def get_representation(cls, element, context):
return ifcopenshell.util.representation.get_representation(element, context)
@classmethod
def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None:
camera = tool.Ifc.get_object(drawing)
if camera and camera.name != name:
camera.name = name
@classmethod
def set_drawing_collection_name(
cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection
+3 -4
View File
@@ -1407,6 +1407,8 @@ class Geometry(bonsai.core.tool.Geometry):
:param representation_item: item to remove.
:param element: item's element. Is used to unmark manual booleans.
"""
# NOTE: we assume it's not the last representation item
# otherwise we probably would need to remove representation too
# NOTE: a lot of shared code with `geometry.remove_representation`
ifc_file = tool.Ifc.get()
shape_aspects: list[ifcopenshell.entity_instance] = []
@@ -1465,10 +1467,7 @@ class Geometry(bonsai.core.tool.Geometry):
cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect)
if representation:
new_items = tuple(set(representation.Items) - {representation_item})
if not new_items:
return
representation.Items = new_items
representation.Items = tuple(set(representation.Items) - {representation_item})
also_consider = list(consider_inverses)
ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider)
+4 -6
View File
@@ -197,16 +197,12 @@ class Ifc(bonsai.core.tool.Ifc):
if not cls.get():
return
# Clear all per-object msgbus subscriptions at once using the dedicated
# owner. After undo/redo, per-object Python wrappers have new
# identities so clearing by individual obj would miss stale
# subscriptions registered with the old wrappers.
bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner)
for obj in bpy.data.objects:
if obj.library:
continue
bpy.msgbus.clear_by_owner(obj)
element = cls.get_entity(obj)
if not element:
continue
@@ -221,6 +217,8 @@ class Ifc(bonsai.core.tool.Ifc):
if obj.library:
continue
bpy.msgbus.clear_by_owner(obj)
style = cls.get_entity(obj)
if not style:
continue
+1 -1
View File
@@ -284,7 +284,7 @@ class IfcGit:
if re.match("^Ifc", obj.name):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
bpy.data.orphans_purge(do_recursive=True)
settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC"))
settings.should_setup_viewport_camera = False
+31 -6
View File
@@ -1063,12 +1063,16 @@ class Loader(bonsai.core.tool.Loader):
@classmethod
def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
# Always compute unit_scale fresh — cls.unit_scale may be stale (e.g. during live
# geometry updates that don't go through the full import pipeline).
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
if not (material := ifcopenshell.util.element.get_material(element)):
return mesh
elif material.is_a("IfcMaterialLayerSetUsage"):
usage = material
layer_set = material.ForLayerSet
offset = usage.OffsetFromReferenceLine * cls.unit_scale
offset = usage.OffsetFromReferenceLine * unit_scale
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
else:
return mesh
@@ -1077,14 +1081,32 @@ class Loader(bonsai.core.tool.Loader):
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
depth_scale = 1.0
if usage.LayerSetDirection == "AXIS2":
co = Vector((0.0, offset, 0.0))
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
elif usage.LayerSetDirection == "AXIS3":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([0.0, 0.0, 1.0])
co = Vector((0.0, 0.0, offset))
# Bisect planes are always horizontal (world Z) for AXIS3.
# For well-formed IFC data, the mesh local Z span equals total_perp_thickness
# (extrusion.Depth is always set to thickness / extrusion_vec.z so that
# extrusion.Depth × extrusion_vec.z = thickness). depth_scale is kept as
# a safety net for IFC files from other authoring tools where the extrusion
# depth may not exactly match the sum of LayerThicknesses.
extrusion_vec = cls.get_extrusion_vector(element).normalized()
ifc_extrusion_depth = None
if body_rep := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body_rep).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
ifc_extrusion_depth = item.Depth
break
total_perp_thickness = sum(l.LayerThickness for l in layer_set.MaterialLayers)
if ifc_extrusion_depth and total_perp_thickness:
depth_scale = abs(extrusion_vec.z) * (ifc_extrusion_depth / total_perp_thickness)
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
@@ -1101,7 +1123,7 @@ class Loader(bonsai.core.tool.Loader):
for i, layer in enumerate(layer_set.MaterialLayers):
if i != last_i:
prev_co = co.copy()
co += no * layer.LayerThickness * cls.unit_scale
co += no * layer.LayerThickness * depth_scale * unit_scale
bisect_geom = bmesh.ops.bisect_plane(
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no
)
@@ -1115,14 +1137,17 @@ class Loader(bonsai.core.tool.Loader):
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(no) >= 0:
dot = (center - co).dot(no)
if dot >= 0:
face.material_index = material_index
has_layer_styles = True
else:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0:
dot_co = (center - co).dot(no)
dot_prev = (center - prev_co).dot(no)
if dot_co < 0 and dot_prev >= 0:
face.material_index = material_index
has_layer_styles = True
-4
View File
@@ -226,10 +226,6 @@ class Material(bonsai.core.tool.Material):
"IfcMaterialProfileSet",
]
@classmethod
def is_type_product(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcTypeProduct")
@classmethod
def add_material_to_set(
cls, material_set: ifcopenshell.entity_instance, material: ifcopenshell.entity_instance
+3 -17
View File
@@ -45,23 +45,9 @@ class Nest(bonsai.core.tool.Nest):
related_object = tool.Ifc.get_entity(related_obj)
if not relating_object or not related_object:
return False
if relating_object == related_object:
return False
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
if not is_compatible_class:
return False
# Prevent cyclic references: walk up the full hierarchy from the
# proposed parent and reject if we encounter the proposed child.
ancestor = ifcopenshell.util.element.get_parent(relating_object)
seen = {relating_object}
while ancestor:
if ancestor == related_object:
return False
if ancestor in seen:
break
seen.add(ancestor)
ancestor = ifcopenshell.util.element.get_parent(ancestor)
return True
if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"):
return True
return False
@classmethod
def disable_editing(cls, obj: bpy.types.Object) -> None:
+13 -615
View File
@@ -16,16 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import math
from typing import Union
import bmesh
import bpy
import mathutils
import numpy as np
from bpy_extras import view3d_utils
from mathutils import Vector
import bonsai.core.tool
@@ -45,7 +41,6 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -74,14 +69,8 @@ class Raycast(bonsai.core.tool.Raycast):
rv3d = context.region_data
assert rv3d
view_location = rv3d.view_matrix.inverted().translation
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
bbox_edges = [
(0,1),(1,2),(2,3),(3,0),
(4,5),(5,6),(6,7),(7,4),
(0,4),(1,5),(2,6),(3,7)
]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -105,23 +94,8 @@ class Raycast(bonsai.core.tool.Raycast):
for v in bbox:
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
transposed_bbox.append(coord_2d)
if not any(transposed_bbox):
transposed_bbox = []
# If there are None values in transposed_bbox it means that there are vertices behind the camera
# so we get the intersection of the edge with the region border
# new_bbox = []
if any(transposed_bbox) and not all(transposed_bbox):
new_bbox = transposed_bbox.copy()
new_bbox = [x for x in new_bbox if x is not None]
for edge in bbox_edges:
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
if point:
new_bbox.append(point)
if new_bbox:
transposed_bbox = new_bbox
if coord_2d is not None:
transposed_bbox.append(coord_2d)
region = context.region
borders = (0, region.width, 0, region.height)
@@ -143,99 +117,6 @@ class Raycast(bonsai.core.tool.Raycast):
return (obj, bbox_2d)
return None
def intersect_edge_region_border(region, space, rv3d, v1, v2):
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
a_view = view_matrix @ p_world_a
b_view = view_matrix @ p_world_b
z_near = -clip_start
za = a_view.z
zb = b_view.z
denom = (zb - za)
if denom == 0.0:
return None, None
t = (z_near - za) / denom
if t < 0.0 or t > 1.0:
return None, None
p_view = a_view.lerp(b_view, t)
cam_world = view_matrix.inverted()
p_world = cam_world @ p_view
return p_world, t
def is_inside_region(pt2d, region):
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
def clamp_to_region_border(point2d, region):
x, y = point2d
x_clamped = max(0.0, min(region.width, x))
y_clamped = max(0.0, min(region.height, y))
return Vector((x_clamped, y_clamped))
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
"""
Use iterative approach: move t toward 0. Returns the first point that is inside region border
"""
t = initial_t_on_segment
for i in range(max_iters):
test_3d = p1.lerp(p2, t)
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
if test_2d is not None and is_inside_region(test_2d, region):
return test_3d, test_2d, t
# move t toward 0 by reducing it by a fraction of its current value
t -= step
# if t is already very small, break
if t <= 1e-6:
break
return None, None, None
# Ensures that all the calculation uses the same direction based on which point is on the screen
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
onscreen_vert = v1
offscreen_vert = v2
else:
onscreen_vert = v2
offscreen_vert = v1
# v2, v1 = v1, v2
clip_start = space.clip_start
view_mat = rv3d.view_matrix
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
if inter_world is None:
print("No intersection with viewport near plane found for the segment.")
return
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
if init_2d is not None and is_inside_region(init_2d, region):
final_world = inter_world
final_2d = init_2d
final_t = initial_t
else:
found_world, found_2d, found_t = find_nearby_onscreen_point(
region, rv3d,
onscreen_vert, offscreen_vert,
t_on_ab,
max_iters=600, step=0.01
)
if found_world is None:
if init_2d is None:
print("Initial projection invalid and iterative search failed.")
return
# fallback: clamp projected point to border via manual mapping
final_2d = clamp_to_region_border(init_2d, region)
final_world = None
final_t = None
# print("Iterative search failed; using clamped 2D:", final_2d)
else:
final_world = found_world
final_2d = found_2d
final_t = found_t
# print(f"Found onscreen point at t={final_t:.4f}")
# print("Final 2D:", final_2d)
return final_2d, v2
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
@@ -351,161 +232,6 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_by_proximity_2d(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj: SnapObj,
):
def divide_vector(start, end, n):
points = []
delta = (end - start) / n
for i in range(1, n):
point = start + i * delta
points.append(point)
return points
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 = []
try:
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
except:
loc = Vector((0, 0, 0))
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
edge_verts = {}
for e in edges:
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
edge_verts[e] = (point, v2_2d)
else:
edge_verts[e] = (v1_2d, point)
else:
edge_verts[e] = (v1_2d, v2_2d)
snap_threshold = 10.0
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Vertex",
"point": snap_obj.verts_3d[i],
"distance": distance / 10,
}
points.append(snap_point)
count = 0
selected_edges = {}
for e in edges:
p0, p1 = edge_verts[e]
p0x, p0y = p0
p1x, p1y = p1
px, py = mouse_pos
# segment vector = p1 - p0
sx = p1x - p0x
sy = p1y - p0y
# seg length squared
seg_len_sq = sx * sx + sy * sy
if seg_len_sq == 0.0:
# degenerate segment: return distance to p0
dx = px - p0x
dy = py - p0y
dist = math.hypot(dx, dy)
return dist, (p0x, p0y), 0.0
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
apx = px - p0x
apy = py - p0y
t = (apx * sx + apy * sy) / seg_len_sq
# clamp to segment
if t <= 0.0:
t_clamped = 0.0
cx, cy = p0x, p0y
elif t >= 1.0:
t_clamped = 1.0
cx, cy = p1x, p1y
else:
t_clamped = t
cx = p0x + sx * t_clamped
cy = p0y + sy * t_clamped
dx = px - cx
dy = py - cy
dist = math.hypot(dx, dy)
if dist <= snap_threshold:
selected_edges[dist] = e
if selected_edges:
min_dist = float("inf")
for key in selected_edges:
if key < min_dist:
min_dist = key
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
division_points = divide_vector(
edge_verts[0], edge_verts[1], 2
) # TODO Make it work for different divisions
for division_point in division_points:
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
distance = (division_point - intersection).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge Center",
"point": division_point.copy(),
"distance": distance,
}
points.append(snap_point)
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
if intersection[0]:
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
distance = (intersection[1] - intersection[0]).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge",
"point": intersection[1].copy(),
"edge_verts": edge_verts,
"distance": distance,
}
points.append(snap_point)
return points
@classmethod
def ray_cast_by_proximity(
cls,
@@ -731,8 +457,7 @@ class Raycast(bonsai.core.tool.Raycast):
if bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
snap_obj = cls.create_snap_obj(obj)
objs_to_raycast.append(snap_obj)
objs_to_raycast.append(obj)
return objs_to_raycast
@@ -749,6 +474,12 @@ class Raycast(bonsai.core.tool.Raycast):
face_index = None
# Wireframes
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
if snap_points:
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
if hit:
hit_world = obj.original.matrix_world @ hit
return obj, hit_world, face_index
return None, None, None
# Meshes
else:
@@ -783,20 +514,19 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
for snap_obj in objs_to_raycast:
for obj in objs_to_raycast:
if not include_wireframes and (
snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
):
continue
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
best_obj = hit_obj
best_obj = snap_obj
best_hit = hit
best_face_index = face_index
@@ -806,81 +536,6 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
objs_to_raycast: list[bpy.types.Object],
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
closest_length_squared = 1.0
closest_obj = None
closest_hit = None
closest_face_index = None
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
closest_snaps = []
hit = None
for snap_obj in objs_to_raycast:
if (snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
for snap in closest_snaps:
if snap["object"] == closest_obj:
snap["is_closest_to_camera"] = True
return closest_snaps
@classmethod
def calculate_snap_threshold(cls, view_distance):
snap_threshold = view_distance / 100
@@ -892,260 +547,3 @@ class Raycast(bonsai.core.tool.Raycast):
if lens < 50:
snap_threshold *= value
return snap_threshold
@classmethod
def create_snap_obj(cls, obj):
for snap_obj in cls.snap_objs:
if obj.name == snap_obj.obj.name:
return snap_obj
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
@classmethod
def clear_snap_objs(cls):
TreeNode.__clear_all__()
SnapObj.__clear_all__()
cls.snap_objs.clear()
class TreeNode:
all = []
def __init__(self, box: tuple):
self.__class__.all.append(self)
self.box = box
self.child_a = None
self.child_b = None
self.edges = []
def __clear_all__():
for instance in TreeNode.all:
del instance
TreeNode.all.clear()
class SnapObj:
max_depth = 9
all = []
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def __clear_all__():
for instance in SnapObj.all:
del instance
SnapObj.all.clear()
def _create_root_node(self) -> TreeNode:
bbox = tool.Blender.get_object_bounding_box(self.obj)
min_point = self.obj.matrix_world @ bbox["min_point"]
max_point = self.obj.matrix_world @ bbox["max_point"]
new_bbox = self.expand_bounding_box((min_point, max_point))
return TreeNode(new_bbox)
def divide_bounding_box_along_longest_axis(
self, min_pt: Vector, max_pt: Vector
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
"""
Divide a bounding box into two equal parts along the axis with the longest dimension.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
Returns:
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
"""
# Calculate the dimensions of the box
dx = max_pt.x - min_pt.x
dy = max_pt.y - min_pt.y
dz = max_pt.z - min_pt.z
# Determine the axis with the longest dimension
if dx >= dy and dx >= dz:
# Divide along the x-axis
mid_x = min_pt.x + dx / 2
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
elif dy >= dx and dy >= dz:
# Divide along the y-axis
mid_y = min_pt.y + dy / 2
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
else:
# Divide along the z-axis
mid_z = min_pt.z + dz / 2
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
return [box1, box2]
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
"""
Expand a 3D bounding box by a given offset.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
offset: The offset to expand the bounding box by.
Returns:
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
"""
min_pt, max_pt = box
# Calculate the new minimum and maximum points
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
return new_min_pt, new_max_pt
def split_box(self, parent: TreeNode, depth: int):
"""
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
Args:
parent: the TreeNode instance that represents the parent node of a BVH Tree.
depth: the depth of the BVH Tree no be used in recursion.
"""
if depth > self.max_depth:
return
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
parent.child_a = TreeNode(box_a)
parent.child_b = TreeNode(box_b)
edges_a = []
edges_b = []
for e in parent.edges:
verts_idx = [v for v in self.obj.data.edges[e].vertices]
verts_coords = []
for idx in verts_idx:
if idx < len(self.obj.data.vertices):
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
edges_a.append(e)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
edges_b.append(e)
parent.child_a.edges = edges_a
parent.child_b.edges = edges_b
self.split_box(parent.child_a, depth + 1)
self.split_box(parent.child_b, depth + 1)
def raycast_box(
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
) -> bool:
"""
Raycast bounding box.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
rays: tuple containing ray origin and ray direction
Returns:
True if hits the box or False otherwise.
"""
box = node.box
min_v = box[0]
max_v = box[1]
t_min = 0.0
t_max = float("inf")
ray_origin, ray_dir = rays
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
# X
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
tmin = min(tx1, tx2)
tmax = max(tx1, tx2)
# Y
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
tmin = max(tmin, min(ty1, ty2))
tmax = min(tmax, max(ty1, ty2))
# Z
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
tmin = max(tmin, min(tz1, tz2))
tmax = min(tmax, max(tz1, tz2))
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
"""
Check if a line segment intersects an axis-aligned bounding box (AABB).
Args:
v1: The first endpoint of the line segment as a mathutils.Vector.
v2: The second endpoint of the line segment as a mathutils.Vector.
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
Returns:
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
"""
bmin, bmax = box
dir = v2 - v1
tmin = 0.0
tmax = 1.0
for i in range(3):
if abs(dir[i]) < 1e-12:
# Line is parallel to slab. If origin not within slab -> no hit.
if v1[i] < bmin[i] or v1[i] > bmax[i]:
return False
else:
ood = 1.0 / dir[i]
t1 = (bmin[i] - v1[i]) * ood
t2 = (bmax[i] - v1[i]) * ood
if t1 > t2:
t1, t2 = t2, t1
if t1 > tmin:
tmin = t1
if t2 < tmax:
tmax = t2
if tmin > tmax:
return False
# If any overlap in [0,1] exists, there's intersection
return (tmax >= 0.0) and (tmin <= 1.0)
def raycast_boxes(
self,
context: bpy.types.Context,
event: bpy.Types.Event,
node: TreeNode,
intersected: Union[TreeNode] = [],
rays: tuple[Vector, Vector] = (),
) -> Union[TreeNode]:
"""
Raycast bounding box subdivisions recursively.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
intersected: list of intersected boxes to use in recursion.
rays: tuple containing ray origin and ray direction
Returns:
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
"""
if not node.child_a:
intersected.append(node)
return intersected
intersects_a = self.raycast_box(context, event, node.child_a, rays)
intersects_b = self.raycast_box(context, event, node.child_b, rays)
if intersects_a:
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
if intersects_b:
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
return intersected
+32 -10
View File
@@ -93,16 +93,38 @@ class Root(bonsai.core.tool.Root):
elif dest.is_a("IfcTypeProduct"):
if not source.RepresentationMaps:
return copied_entities
dest.RepresentationMaps = [
ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
m,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
copied_entities=copied_entities,
)
for m in source.RepresentationMaps
]
# Copy representation maps while preserving mapped representation structures
new_maps = []
for i, rep_map in enumerate(source.RepresentationMaps):
source_rep = rep_map.MappedRepresentation
# Copy the map itself
new_map = ifcopenshell.util.element.copy(tool.Ifc.get(), rep_map)
# Handle the mapped representation - preserve mapping structure if present
if (
source_rep.RepresentationType == "MappedRepresentation"
and len(source_rep.Items) == 1
and source_rep.Items[0].is_a("IfcMappedItem")
):
# This is a mapped representation - preserve the structure
new_rep = ifcopenshell.util.element.copy(tool.Ifc.get(), source_rep)
new_rep.Items = [ifcopenshell.util.element.copy(tool.Ifc.get(), item) for item in source_rep.Items]
new_map.MappedRepresentation = new_rep
else:
# Not a mapped representation - use copy_deep as before
new_map.MappedRepresentation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
source_rep,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
copied_entities=copied_entities,
)
new_maps.append(new_map)
dest.RepresentationMaps = new_maps
return copied_entities
@classmethod
+51 -25
View File
@@ -360,7 +360,6 @@ class Snap(bonsai.core.tool.Snap):
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
# Polyline
polyline_props = tool.Model.get_polyline_props()
try:
@@ -389,31 +388,58 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
# Wireframes
# For wireframe we have to get all the objects so we can further calculate edge intersection
for snap_obj in objs_to_raycast:
for snap in closest_snaps:
if snap_obj.obj == snap["object"]:
if xray_mode:
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
else:
# If it is a solid object that is closest to camera it ignores all the rest
if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
closest_snap = [snap] # discards objects that aren't the closest
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
closest_snap.append(point)
detected_snaps = closest_snap
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
detected_snaps.append(point)
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
):
results = []
for obj in objs_to_raycast:
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
else:
results = []
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
for result in results:
snap_obj = result[0]
hit = result[1]
face_index = result[2]
if hit is not None:
# Wireframes
if snap_obj.type in {"EMPTY", "CURVE"} or (
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
):
continue
# Meshes
else:
# Add face snap
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": snap_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
detected_snaps.append(snap_point)
# Add vertex and edge snap
snap_points = tool.Raycast.ray_cast_by_proximity(
context, event, snap_obj, snap_obj.data.polygons[face_index]
)
if snap_points:
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
cut_snaps = []
+151 -124
View File
@@ -21,13 +21,13 @@ from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Iterable
from math import pi
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.attribute
import ifcopenshell.api.geometry
import ifcopenshell.api.type
import ifcopenshell.geom
import ifcopenshell.util.classification
@@ -991,107 +991,114 @@ class Spatial(bonsai.core.tool.Spatial):
return poly
@classmethod
def create_object(cls, name: str) -> bpy.types.Object:
mesh = bpy.data.meshes.new(name=name)
def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh:
"""
:param h: Height, in meters.
:param polygon_is_si: Should be True if `poly` is defined in meters.
"""
mat = Matrix()
bm = bmesh.new()
bm.verts.index_update()
bm.edges.index_update()
mat_invert = mat.inverted()
si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
new_verts = [
# bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1]
bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion))
for v in shapely.get_exterior_ring(poly).coords[0:-1]
]
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
bm.verts.index_update()
bm.edges.index_update()
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.triangle_fill(bm, edges=bm.edges)
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges)
if h != 0:
extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)]
bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return bm
@classmethod
def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object:
mesh = cls.get_named_mesh_from_bmesh(name, bmesh)
obj = cls.get_named_obj_from_mesh(name, mesh)
return obj
@classmethod
def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
obj = bpy.data.objects.new(name, mesh)
return obj
@classmethod
def set_obj_origin_to_polygon_center(
cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
) -> None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
centroid = poly.centroid
if polygon_is_si:
obj.location = Vector((centroid.x, centroid.y, 0))
else:
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh:
mesh = bpy.data.meshes.new(name=name)
bmesh.to_mesh(mesh)
bmesh.free()
return mesh
@classmethod
def get_2d_vertices_from_polygon(
cls,
poly: Polygon,
obj: bpy.types.Object,
polygon_is_si: bool = True,
) -> list[list[float]]:
"""Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
:param poly: The polygon in world space.
:param obj: The Blender object whose local space is used.
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
:return: List of [x, y] coordinates (not closed).
"""
ifc_file = tool.Ifc.get()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
bpy.context.view_layer.update()
mat_inv = obj.matrix_world.inverted()
coords_2d = []
for v in shapely.get_exterior_ring(poly).coords[:-1]:
world_si = Vector((v[0], v[1], 0))
if not polygon_is_si:
world_si = world_si * unit_scale
local_si = mat_inv @ world_si
coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
return coords_2d
def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
active_obj = cls.get_active_obj()
mat = active_obj.matrix_world
mesh.transform(mat.inverted())
mesh.update()
return mesh
@classmethod
def set_extrusion_representation_from_polygon(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
poly: Polygon,
depth_ifc: float,
polygon_is_si: bool = True,
) -> None:
"""Create or replace the IFC body representation from a polygon extrusion.
:param obj: The Blender object.
:param element: The IFC product entity.
:param poly: The polygon in world space.
:param depth_ifc: The extrusion depth in IFC file units.
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
"""
ifc_file = tool.Ifc.get()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
curve = builder.polyline(coords_2d, closed=True)
item = builder.extrude(curve, magnitude=depth_ifc)
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if old_body:
context = old_body.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
else:
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_body,
)
def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None:
active_obj = bpy.context.active_object
old_mesh = active_obj.data
old_mesh_name = old_mesh.name
assert active_obj and isinstance(old_mesh, bpy.types.Mesh)
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id
tool.Geometry.change_object_data(active_obj, mesh, is_global=True)
tool.Ifc.edit(active_obj)
tool.Blender.remove_data_block(old_mesh)
# Rename after old mesh is removed to avoid .001 suffix.
mesh.name = old_mesh_name
@classmethod
def set_space_representation_from_polygon(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
poly: Polygon,
h: float,
polygon_is_si: bool = True,
) -> None:
"""Create or replace the IFC body representation of a space from a polygon.
def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None:
mat = obj.matrix_world
inverted = mat.inverted()
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
global_bbox_center = mat @ local_bbox_center
:param h: The height in SI (meters).
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
oldLoc = obj.location
newLoc = global_bbox_center
diff = newLoc - oldLoc
for vert in obj.data.vertices:
aux_vector = mat @ vert.co
aux_vector = aux_vector - diff
vert.co = inverted @ aux_vector
obj.location = newLoc
@classmethod
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
mat = obj.matrix_world
inverted = mat.inverted()
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
global_bbox_center = mat @ local_bbox_center
global_obj_origin = global_bbox_center
global_obj_origin.z = 0
oldLoc = obj.location
newLoc = global_obj_origin
diff = newLoc - oldLoc
for vert in obj.data.vertices:
aux_vector = mat @ vert.co
aux_vector = aux_vector - diff
vert.co = inverted @ aux_vector
obj.location = newLoc
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
@@ -1138,53 +1145,65 @@ class Spatial(bonsai.core.tool.Spatial):
if z != 0:
obj.location = obj.location + Vector((0, 0, z))
@classmethod
def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]:
points = []
vectors = [v.co for v in obj.data.vertices.values()]
for vector in vectors:
points.append(vector.xy)
points.append(vectors[0].xy)
return points
@classmethod
def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]:
model = tool.Ifc.get()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model)
_points = []
for p in points:
_p = list(p)
_p[0] /= unit_scale
_p[1] /= unit_scale
_points.append(_p)
return _points
@classmethod
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None:
body = cls.get_body_representation(obj)
model = tool.Ifc.get()
extrusion = tool.Model.get_extrusion(body)
area = extrusion.SweptArea
old_area = area.OuterCurve
builder = ifcopenshell.util.shape_builder.ShapeBuilder(model)
outer_curve = builder.polyline(vertices, closed=True)
area.OuterCurve = outer_curve
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area)
@classmethod
def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
element = tool.Ifc.get_entity(obj)
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@classmethod
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
bonsai.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class="IfcSpace",
should_add_representation=False,
)
bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace")
@classmethod
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
# TODO this code looks in the wrong spot and suspicious
props = tool.Model.get_model_props()
ifc_file = tool.Ifc.get()
relating_type_id = props.relating_type_id
relating_type = ifc_file.by_id(int(relating_type_id))
relating_type = tool.Ifc.get().by_id(int(relating_type_id))
ifc_class = relating_type.is_a()
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
bonsai.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=instance_class,
should_add_representation=False,
)
bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class)
element = tool.Ifc.get_entity(obj)
assert element
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
@classmethod
def set_covering_representation_from_polygon(
cls,
obj: bpy.types.Object,
poly: Polygon,
polygon_is_si: bool = True,
) -> None:
"""Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
element = tool.Ifc.get_entity(obj)
relating_type = ifcopenshell.util.element.get_type(element)
material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
depth = 0.0
if material and material.is_a("IfcMaterialLayerSet"):
depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
@classmethod
def assign_relating_type_to_element(
cls,
@@ -1195,6 +1214,14 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
@classmethod
def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
+10 -1
View File
@@ -75,7 +75,16 @@ class Type(bonsai.core.tool.Type):
@classmethod
def get_model_types(cls) -> list[ifcopenshell.entity_instance]:
return tool.Ifc.get().by_type("IfcTypeProduct")
ifc_file = tool.Ifc.get()
types = ifc_file.by_type("IfcElementType")
if tool.Ifc.get_schema() == "IFC2X3":
types += ifc_file.by_type("IfcWindowStyle")
types += ifc_file.by_type("IfcDoorStyle")
types += ifc_file.by_type("IfcSpatialStructureElementType")
else:
types += ifc_file.by_type("IfcSpatialElementType")
types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False)
return types
@classmethod
def get_object_data(cls, obj: bpy.types.Object) -> Union[bpy.types.ID, None]:
@@ -19,5 +19,4 @@ This chapter covers how you can help contribute to Bonsai.
undo_system
writing_docs
debugging
maintenance
ide/index
@@ -1,65 +0,0 @@
Maintenance
===========
This page documents what needs to be updated in various maintenance scenarios.
Python Version Added or Removed
--------------------------------
When adding or removing a supported Python version, update the following:
.. list-table::
:header-rows: 1
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
- ``MIN_IOS_PY_VERSION``
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-ifcopenshell-python.yml``
- ``pyver`` matrix
* - ``nix/build-all.py``
- ``PYTHON_VERSIONS`` list
* - ``src/bsdd/pyproject.toml``
- ``requires-python``
* - ``src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst``
- add or remove the row in the ZIP packages table
* - ``src/ifcopenshell-python/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/ifcopenshell-python/pyproject.toml``
- ``requires-python``
* - ``src/ifcopenshell-python/test/test_package.py``
- ``SUPPORTED_PY_VERSIONS`` tuple
* - ``win/build-all-win.py``
- ``PYTHON_VERSIONS`` list
Blender Version Updated
-----------------------
When a new Blender version is released and supported:
.. list-table::
:header-rows: 1
* - File
- What to update
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
Blender's Bundled Python Version Updated
-----------------------------------------
When Blender ships with a new Python version:
.. list-table::
:header-rows: 1
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``src/bonsai/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/bonsai/scripts/dev_environment.py``
- ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
-23
View File
@@ -1,23 +0,0 @@
"""Clone or update Bonsai external dependencies.
Must be run from the repository root.
"""
import subprocess
from pathlib import Path
DEPS = [
("https://projects.blender.org/pioverfour/sun_position.git", "sun_position"),
("https://github.com/kevancress/MeasureIt_ARCH", "MeasureIt_ARCH"),
("https://github.com/nortikin/sverchok.git", "sverchok"),
]
base = Path("src/bonsai/external_dependencies")
base.mkdir(parents=True, exist_ok=True)
for url, name in DEPS:
path = base / name
if not path.exists():
subprocess.check_call(["git", "clone", url, str(path)])
else:
subprocess.check_call(["git", "-C", str(path), "pull", "--rebase"])
+2 -2
View File
@@ -273,10 +273,10 @@ if BPY_IS_LOADED:
f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.",
)
from ui_translate.settings import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
from ui_translate.settings import ( # pyright: ignore[reportMissingImports]
settings as ui_translate_settings,
)
from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports]
UI_OT_i18n_updatetranslation_init_settings,
)
+1 -3
View File
@@ -23,9 +23,7 @@ import bpy
# sys.path.append('C:\Program Files\Python37\Lib\site-packages')
import lxml.etree
from bspy import ( # ty: ignore[unresolved-import]
Gbxml, # pyright: ignore[reportMissingImports]
)
from bspy import Gbxml # pyright: ignore[reportMissingImports]
class GbxmlExporter:
@@ -22,7 +22,7 @@
from math import pi
from pathlib import Path
import boltspy as bolts # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
import boltspy as bolts # pyright: ignore[reportMissingImports]
import ifcopenshell.api
import ifcopenshell.api.material
import ifcopenshell.api.project
+1 -1
View File
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
import pymeshlab # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
import pymeshlab # pyright: ignore[reportMissingImports]
class Obj2Ifc:
+1 -1
View File
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
import pywavefront # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
import pywavefront # pyright: ignore[reportMissingImports]
class Obj2Ifc:
+3 -108
View File
@@ -2,7 +2,7 @@
Feature: Covering
Covers covering tool.
Scenario: Add flooring from walls
Scenario: Execute generate flooring coverings from walls
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
@@ -24,9 +24,9 @@ Scenario: Add flooring from walls
And the cursor is at "0,2.0,0"
And I set "scene.BIMModelProperties.length" to "1.9"
And I press "bim.add_occurrence"
# Set COV30 predefined type to FLOORING.
# add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I look at the "Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "FLOORING"
And I click "Save Attributes"
@@ -42,108 +42,3 @@ Scenario: Add flooring from walls
Then the object "IfcCovering/Covering0" exists
And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0"
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
Scenario: Add ceiling from walls
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.change_layer_length(length=3.6)"
And the cursor is at "3.6,0.1,3"
And I set "scene.BIMModelProperties.length" to "2.0"
And I press "bim.add_occurrence"
And the cursor is at "3.5,2.1,3"
And I set "scene.BIMModelProperties.length" to "3.5"
And I press "bim.add_occurrence"
And the cursor is at "0,2.0,0"
And I set "scene.BIMModelProperties.length" to "1.9"
And I press "bim.add_occurrence"
# Set COV30 predefined type to CEILING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "CEILING"
And I click "Save Attributes"
# Run the operator with ceiling height = 2.7 (default).
When the object "IfcWall/Wall" is selected
And additionally the object "IfcWall/Wall.001" is selected
And additionally the object "IfcWall/Wall.002" is selected
And additionally the object "IfcWall/Wall.003" is selected
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_instance_ceiling_coverings_from_walls"
Then the object "IfcCovering/Covering0" exists
And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7"
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
Scenario: Add flooring from cursor
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the cursor is at "1.1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.001" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the cursor is at "0,.9,0"
And I press "bim.add_occurrence"
And the cursor is at "-1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.003" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the object "IfcWall/Wall.003" is moved to "0,0,0"
# Set COV30 predefined type to FLOORING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "FLOORING"
And I click "Save Attributes"
# Generate covering from cursor inside the room.
When the cursor is at "0.5,0.5,0"
And I deselect all objects
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_instance_flooring_covering_from_cursor"
Then the object "IfcCovering/Covering" exists
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
Scenario: Add ceiling from cursor
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the cursor is at "1.1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.001" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the cursor is at "0,.9,0"
And I press "bim.add_occurrence"
And the cursor is at "-1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.003" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the object "IfcWall/Wall.003" is moved to "0,0,0"
# Set COV30 predefined type to CEILING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "CEILING"
And I click "Save Attributes"
# Generate covering from cursor inside the room.
When the cursor is at "0.5,0.5,0"
And I deselect all objects
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_instance_ceiling_covering_from_cursor"
Then the object "IfcCovering/Covering" exists
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
+1 -121
View File
@@ -310,129 +310,9 @@ Scenario: Create sheet - with a drawing added to it
When I click "OUTPUT"
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall"
Scenario: Enable editing text
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
And I press "bim.add_annotation"
And the object "IfcAnnotation/TEXT" is selected
And I look at the "BIM_PT_text" panel
When I click "Enable Editing Text"
Then I see "Literals:"
And I don't see "FontSize"
Scenario: Disable editing text
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
And I press "bim.add_annotation"
And the object "IfcAnnotation/TEXT" is selected
And I look at the "BIM_PT_text" panel
And I click "Enable Editing Text"
When I click "CANCEL"
Then I see "FontSize"
And I don't see "Literals:"
Scenario: Edit text - no changes
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
And I press "bim.add_annotation"
And the object "IfcAnnotation/TEXT" is selected
And I look at the "BIM_PT_text" panel
And I click "Enable Editing Text"
When I click "Edit Text"
Then I see "FontSize"
And I don't see "Literals:"
Scenario: Edit text - change literal
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
And I press "bim.add_annotation"
And the object "IfcAnnotation/TEXT" is selected
And I look at the "BIM_PT_text" panel
And I click "Enable Editing Text"
And I set the "Literal" property to "Hello World"
When I click "Edit Text"
Then I see "Hello World"
Scenario: Add text literal
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
And I press "bim.add_annotation"
And the object "IfcAnnotation/TEXT" is selected
And I look at the "BIM_PT_text" panel
And I click "Enable Editing Text"
And I click the "ADD" after the text "Literals:"
And I set the "2nd Literal" property to "New Literal"
When I click "Edit Text"
Then I see "New Literal"
Scenario: Remove text literal
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
And I press "bim.add_annotation"
And the object "IfcAnnotation/TEXT" is selected
And I look at the "BIM_PT_text" panel
And I click "Enable Editing Text"
And I set the "Literal" property to "Keep This"
And I click the "ADD" after the text "Literals:"
And I set the "2nd Literal" property to "Remove This"
And I click "Edit Text"
And I click "Enable Editing Text"
When I click the "2nd" "X"
And I click "Edit Text"
Then I see "Keep This"
And I don't see "Remove This"
Scenario: Add reference image
Given an empty IFC project
And I save IFC project
When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg', x_length=1, y_length=0.565)"
When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')"
Then the object "IfcAnnotation/image" exists
And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0."
@@ -422,24 +422,6 @@ Scenario: Enable editing material set item
When I press "bim.enable_editing_material_set_item(material_set_item={material_profile})"
Then nothing happens
Scenario: Edit layer item defaults null IsVentilated to FALSE in UI
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I press "bim.add_material()"
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
And the variable "layer" is "{ifc}.by_type('IfcMaterialLayer')[0].id()"
And I press "bim.enable_editing_material_set_item(material_set_item={layer})"
When I evaluate expression "attrs = bpy.context.active_object.BIMObjectMaterialProperties.material_set_item_attributes; is_vent = next(a for a in attrs if a.name == 'IsVentilated'); assert is_vent.enum_value == 'FALSE'; assert is_vent.is_null is True"
And I press "bim.edit_material_set_item(material_set_item={layer})"
Then I evaluate expression "assert {ifc}.by_id({layer}).IsVentilated is None"
Scenario: Add material set layer
Given an empty IFC project
And I add a cube
+1 -39
View File
@@ -57,7 +57,7 @@ Scenario: Add one type from the Construction Type Browser
Scenario: Add grid
Given an empty IFC project
When I press "bim.add_grid"
When I press "mesh.add_grid"
Then the object "IfcGrid/Grid" is an "IfcGrid"
And the object "IfcGridAxis/A" is an "IfcGridAxis"
And the object "IfcGridAxis/B" is an "IfcGridAxis"
@@ -396,44 +396,6 @@ Scenario: Add a slab
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
Scenario: Extend walls to underside
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcSlab/Slab" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Extend walls to underside - extending to a tessellated gable roof
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
# Create gable roof: a cube turned into a prism with a ridge.
And I add a cube of size "1" at "0.5,0.05,3"
And the object "Cube" is selected
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
And I press "bim.assign_class"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcRoof/Cube" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Enable editing a slab profile
Given an empty IFC project
And I load the demo construction library
+1 -1
View File
@@ -914,7 +914,7 @@ Scenario: Export IFC - with moved object location synchronised
Scenario: Export IFC - with moved grid axis location synchronised
Given an empty IFC project
And I press "bim.add_grid"
And I press "mesh.add_grid"
When the object "IfcGridAxis/01" is moved to "1,0,0"
And I save IFC project
And I load previously saved IFC project
+7 -77
View File
@@ -133,11 +133,7 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
if args:
props, name = args
else:
props = kwargs.get("data")
name = kwargs.get("property")
props, name = args
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -246,7 +242,7 @@ class TemplateListItemSpy(PanelSpy):
self.spied_props: list[dict[str, Any]] = []
self.spied_operators: list[dict[str, Any]] = []
if len(signature(blender_panel.draw_item).parameters) == 8:
blender_panel.draw_item( # ty:ignore[missing-argument]
blender_panel.draw_item(
self,
bpy.context,
self,
@@ -382,7 +378,7 @@ def i_look_at_the_panel_panel(panel: str) -> None:
# Option to provide explicit panel name if panel names overlap.
panel_class = getattr(bpy.types, panel, None)
if panel_class is None or panel_class.bl_rna.base.name not in ("Panel", "Operator", "Menu", "UIList"):
if panel_class is None:
global ui_name_cache
create_ui_name_cache()
if panel not in ui_name_cache:
@@ -394,32 +390,6 @@ def i_look_at_the_panel_panel(panel: str) -> None:
panel_spy.refresh_spy()
@given(parsers.parse("I look at the tool header"))
@when(parsers.parse("I look at the tool header"))
@then(parsers.parse("I look at the tool header"))
def i_look_at_the_tool_header() -> None:
from bonsai.bim.module.model.workspace import EditObjectUI
class MockRegion:
type = "UI"
class MockContext:
def __getattr__(self, name):
if name == "region":
return MockRegion()
return getattr(bpy.context, name)
global panel_spy
panel_spy = PanelSpy(EditObjectUI)
panel_spy.is_spy_dirty = False
panel_spy.spied_attr = None
panel_spy.spied_labels = []
panel_spy.spied_props = []
panel_spy.spied_operators = []
panel_spy.spied_lists = []
EditObjectUI.draw(MockContext(), panel_spy)
@given(parsers.parse('I open the "{name}" menu'))
@when(parsers.parse('I open the "{name}" menu'))
@then(parsers.parse('I open the "{name}" menu'))
@@ -640,9 +610,8 @@ def i_see_the_prop_property_is_value(prop, value):
@then(parsers.parse('I set the "{prop}" property to "{value}"'))
def i_set_the_prop_property_to_value(prop: str, value: str):
"""
:param prop: Could be either property name, property text, property icon,
property index (e.g. "1st", "2nd", "5th"), or Nth named property
(e.g. "2nd Literal" for the 2nd property called "Literal").
:param prop: Could be either property name, property text, property icon
or property index (e.g. "1st", "2nd", "5th").
:param value:
For boolean propeties - 'TRUE' or 'FALSE'.
"""
@@ -650,28 +619,12 @@ def i_set_the_prop_property_to_value(prop: str, value: str):
assert panel_spy
panel_spy.refresh_spy()
is_nth = False
is_nth_named = False
nth_target = 0
prop_name = prop
if " " in prop and prop[0].isnumeric():
parts = prop.split(" ", 1)
if parts[0].endswith(("st", "nd", "th")):
is_nth_named = True
nth_target = int(parts[0][:-2]) - 1
prop_name = parts[1]
elif prop[0].isnumeric() and prop.endswith(("st", "nd", "th")):
if prop[0].isnumeric() and prop.endswith(("st", "nd", "th")):
is_nth = True
named_count = 0
for nth, spied_prop in enumerate(panel_spy.spied_props):
if is_nth and nth != int(prop[:-2]) - 1:
continue
if is_nth_named:
if prop_name not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]):
continue
if named_count != nth_target:
named_count += 1
continue
elif not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]):
if not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]):
continue
if spied_prop["prop_type"] == "BOOLEAN":
if value == "TRUE":
@@ -920,29 +873,6 @@ def i_click_button(button):
_i_click_button_on_panel(button, panel_spy)
@given(parsers.parse('I click the "{nth}" "{button}"'))
@when(parsers.parse('I click the "{nth}" "{button}"'))
@then(parsers.parse('I click the "{nth}" "{button}"'))
def i_click_the_nth_button(nth, button):
"""
:param nth: Ordinal like "1st", "2nd", "3rd" to select the Nth matching button.
:param button: The text or icon of the button to click.
"""
assert panel_spy
panel_spy.refresh_spy()
target = int(nth[:-2]) - 1
count = 0
for spied_operator in panel_spy.spied_operators:
if spied_operator["text"] == button or spied_operator["icon"] == button:
if count == target:
spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"])
panel_spy.is_spy_dirty = True
return
count += 1
debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)])
assert False, f"Could not find {nth} {button}:\n{debug}"
@given(parsers.parse('I click the "{button}" after the text "{text}"'))
@when(parsers.parse('I click the "{button}" after the text "{text}"'))
@then(parsers.parse('I click the "{button}" after the text "{text}"'))

Some files were not shown because too many files have changed in this diff Show More