diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml
index 8beddfe668..3b52198f3a 100644
--- a/.github/workflows/ci-bonsai-daily.yml
+++ b/.github/workflows/ci-bonsai-daily.yml
@@ -53,6 +53,11 @@ jobs:
name: "MacOS ARM Build",
short_name: macosm1,
}
+ exclude:
+ # Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
+ - pyver: py313
+ config:
+ short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a2fe8ce5d..65031e83d9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,7 +53,6 @@ jobs:
python -m pip install --upgrade pip
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
pip install src/bcf --no-deps
- pip install git+https://github.com/zdhoward/aud
pip install pytest-xdist==3.8.0
- name: Install C++ dependencies
diff --git a/pyproject.toml b/pyproject.toml
index 13f3b15ba6..4e9d9687c5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ dependencies = [
"black==26.1.0",
"ruff==0.14.14",
"poethepoet",
- "gersemi==0.25.1",
+ "gersemi==0.25.4",
]
[tool.black]
diff --git a/src/bonsai/Dockerfile b/src/bonsai/Dockerfile
new file mode 100644
index 0000000000..9805ec0530
--- /dev/null
+++ b/src/bonsai/Dockerfile
@@ -0,0 +1,22 @@
+# Build Bonsai .zip.
+# Usage:
+# # While in `bonsai` folder.
+# docker build -t bonsai-dist .
+# # Output zip file will be in `dist` folder.
+# # See possible values for variables in `Makefile`'s `dist` target description.
+# docker run --rm -v "$PWD/../../:/work" -w /work -e PLATFORM=win -e PYVERSION=py313 bonsai-dist
+
+FROM ubuntu:24.04
+RUN apt-get update
+RUN apt-get install -y make git wget unzip zip
+
+# Python
+RUN apt-get install -y software-properties-common
+RUN add-apt-repository ppa:deadsnakes/ppa
+RUN apt-get update
+RUN apt-get install -y python3.11 python3.11-venv
+
+RUN apt-get install -y npm
+
+WORKDIR /work
+CMD cd src/bonsai && make dist PLATFORM=$PLATFORM PYVERSION=$PYVERSION
diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile
index 5b484f9942..6b4704b7b3 100644
--- a/src/bonsai/Makefile
+++ b/src/bonsai/Makefile
@@ -48,18 +48,33 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3)
VERSION_DATE:=$(shell date '+%y%m%d')
LAST_COMMIT_HASH:=$(shell git rev-parse HEAD)
LAST_COMMIT_DATE:=$(shell git show -s --format=%cI)
-PYVERSION:=py310
PYPI_IMP:=cp
-ifeq ($(PYVERSION), py311)
-PYLIBDIR:=python3.11
-PYNUMBER:=311
-PYPI_VERSION:=3.11
+ifdef PYVERSION
+SUPPORTED_PYVERSIONS := py311 py312 py313
+
+ifeq ($(filter $(PYVERSION),$(SUPPORTED_PYVERSIONS)),)
+$(error Unsupported PYVERSION=$(PYVERSION). Must be one of $(SUPPORTED_PYVERSIONS))
+endif
+
+PYMINOR:=$(subst py3,,$(PYVERSION))
+PYLIBDIR:=python3.$(PYMINOR)
+PYNUMBER:=3$(PYMINOR)
+PYPI_VERSION:=3.$(PYMINOR)
+endif # def PYVERSION
+
+
+ifdef PLATFORM
+SUPPORTED_PLATFORMS := linux macos macosm1 win
+
+ifeq ($(filter $(PLATFORM),$(SUPPORTED_PLATFORMS)),)
+$(error Unsupported PLATFORM=$(PLATFORM). Must be one of $(SUPPORTED_PLATFORMS))
+endif
+
+ifeq ($(PLATFORM),macos)
+ifeq ($(PYVERSION),py313)
+$(error Blender 5.1 with Python 3.13 doesn't support intel macOS.)
endif
-ifeq ($(PYVERSION), py312)
-PYLIBDIR:=python3.12
-PYNUMBER:=312
-PYPI_VERSION:=3.12
endif
ifeq ($(PLATFORM), linux)
@@ -86,6 +101,8 @@ PYPI_PLATFORM:=--platform win_amd64
BLENDER_PLATFORM:=windows-x64
endif
+endif # def PLATFORM
+
# Current build commit hash.
OLD:=e8eb5e4
.PHONY: bump
@@ -99,7 +116,10 @@ endif
.PHONY: dist
dist:
ifndef PLATFORM
- $(error PLATFORM is not set)
+ $(error PLATFORM is not set. Example values: $(SUPPORTED_PLATFORMS).)
+endif
+ifndef PYVERSION
+ $(error PYVERSION is not set. Example values: $(SUPPORTED_PYVERSIONS). )
endif
rm -rf build
mkdir -p build
@@ -125,9 +145,6 @@ endif
cd ../ifc5d && make dist && mv dist/*.whl ../bonsai/build/wheels/
cd ../ifccityjson && make dist && mv dist/*.whl ../bonsai/build/wheels/
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download GitPython --dest=./wheels
- # Provides audio playback for costing
- # This is a REALLY IMPORTANT feature
- cd build && . env/$(VENV_ACTIVATE) && $(PIP) wheel git+https://github.com/zdhoward/aud --wheel-dir=./wheels
# IfcOpenShell dependency - support for new typing features
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download typing_extensions --dest=./wheels
# Required by IfcCSV
@@ -189,7 +206,7 @@ endif
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download tzfpy $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
# pyradiance is using different platform versions than defaults in our makefile.
ifeq ($(PLATFORM), linux)
- cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
+ cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
else ifeq ($(PLATFORM), macos)
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
else
@@ -267,6 +284,11 @@ else
$(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml
endif
+# Blender 5.1+ requires Python 3.13.
+ifeq ($(PYVERSION), py313)
+ $(SED) 's/blender_version_min = "4\.2\.0"/blender_version_min = "5.1.0"/' build/bonsai/blender_manifest.toml
+endif
+
$(SED) "s/os-arch/$(BLENDER_PLATFORM)/" build/bonsai/blender_manifest.toml
# Provides bonsai Add-on functionality
@@ -288,12 +310,6 @@ endif
fi
mv build/wheels/*.whl build/bonsai/wheels/
- # Temporary workaround for Blender not handling non-3.11 wheels #5743.
- # Use '-n' as on macos x86-64 doesn't have a binary wheel and it autoincludes 'cp311'.
- prev_whl_name=$$(find build/bonsai/wheels/tzfpy-*.whl); \
- whl_name=$$(echo $$prev_whl_name | sed "s/-cp39-/-cp$(PYNUMBER)-/"); \
- mv --update=none "$$prev_whl_name" "$$whl_name";
-
ifneq ($(PLATFORM), linux)
# Safeguard: in case one of `pip download` will break,
# it will produce a linux wheel for non-linux build (our github action machine is using linux).
@@ -336,7 +352,11 @@ test:
.PHONY: test-core
test-core:
+ifndef MODULE
pytest -p no:pytest-blender test/core
+else
+ pytest -p no:pytest-blender test/core/test_${MODULE}.py
+endif
.PHONY: test-bim
test-bim:
diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py
index be6272c3ab..e9f23afece 100644
--- a/src/bonsai/bonsai/bim/module/aggregate/operator.py
+++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py
@@ -224,7 +224,7 @@ class BIM_OT_add_aggregate(bpy.types.Operator, tool.Ifc.Operator):
tool.Collector,
tool.Spatial,
container=current_container,
- element_obj=aggregate,
+ objs=[aggregate],
)
core.assign_object(tool.Ifc, tool.Aggregate, tool.Collector, relating_obj=aggregate, related_obj=obj)
diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py
index 67a9ed0853..9f172ce2bb 100644
--- a/src/bonsai/bonsai/bim/module/drawing/__init__.py
+++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py
@@ -45,6 +45,7 @@ classes = (
operator.CleanWireframes,
operator.ContractSheet,
operator.ConvertSVGToDXF,
+ operator.CopyTextToSelection,
operator.CreateDrawing,
operator.CreateSheets,
operator.DisableAddAnnotationType,
@@ -115,7 +116,6 @@ classes = (
prop.BIMCameraProperties,
prop.ElementValueRow,
prop.LiteralProps,
- prop.LiteralApplySettings,
prop.BIMTextProperties,
prop.BIMAssignedProductProperties,
prop.BIMAnnotationProperties,
diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py
index ed457a7bbf..a4396d84c5 100644
--- a/src/bonsai/bonsai/bim/module/drawing/data.py
+++ b/src/bonsai/bonsai/bim/module/drawing/data.py
@@ -355,8 +355,6 @@ class DecoratorData:
font_size = FONT_SIZES[font_size_type]
symbol = tool.Drawing.get_annotation_symbol(element)
newline_at = pset_data.get("Newline_At", 0)
- reverse_list = pset_data.get("Reverse_List", False)
- list_separator = pset_data.get("List_Separator") or ", "
# other attributes
literals = tool.Drawing.get_text_literal(obj, return_list=True)
@@ -384,8 +382,6 @@ class DecoratorData:
"FontSize": font_size,
"Symbol": symbol,
"Newline_At": newline_at,
- "Reverse_List": reverse_list,
- "List_Separator": list_separator,
}
@classmethod
diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py
index 0a6bc967bf..705fabfcf9 100644
--- a/src/bonsai/bonsai/bim/module/drawing/decoration.py
+++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py
@@ -1762,40 +1762,34 @@ class CutDecorator:
shader.uniform_float("color", color)
batch.draw(shader)
- def cache_camera_matrix(self):
+ def cache_camera_matrix(self) -> None:
+ assert bpy.context.scene and bpy.context.scene.camera
obj = bpy.context.scene.camera
- # Explicit `dtype` for Blender <5.0 compatibility.
DecoratorData.camera_location_checksum = repr(
- np.array(obj.matrix_world.translation, dtype=np.float32).tobytes()
+ tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()
)
- DecoratorData.camera_rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
+ DecoratorData.camera_rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
- def is_camera_moved(self):
+ def is_camera_moved(self) -> bool:
if not DecoratorData.camera_location_checksum:
self.cache_camera_matrix()
return True # Let's be conservative
+
+ assert bpy.context.scene and bpy.context.scene.camera
obj = bpy.context.scene.camera
# Handle both old float64 and new float32 checksums for version compatibility
- loc_checksum_bytes = eval(DecoratorData.camera_location_checksum)
- if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64)
- loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32)
- else: # New format: 3 * 4 bytes (float32)
- loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32)
-
- loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten()
+ loc_checksum_bytes: bytes = eval(DecoratorData.camera_location_checksum)
+ loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3)
+ loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation)
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
self.cache_camera_matrix()
return True
# Handle both old float64 and new float32 checksums for version compatibility
- rot_checksum_bytes = eval(DecoratorData.camera_rotation_checksum)
- if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64)
- rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3)
- else: # New format: 9 * 4 bytes (float32)
- rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3)
-
- rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32)
+ rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
+ 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))
if angle_rad > 0.0017453292519943296: # 0.1 degrees
diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py
index 365ff90ee3..d02e4c6c76 100644
--- a/src/bonsai/bonsai/bim/module/drawing/operator.py
+++ b/src/bonsai/bonsai/bim/module/drawing/operator.py
@@ -3181,169 +3181,34 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_text"
bl_label = "Edit Text"
bl_description = "Save changes to the text annotation and\ndisable the text editing options"
-
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
- obj = context.active_object
- props = tool.Drawing.get_text_props(obj)
-
- captured_apply_settings = {
- "apply_font_size_to_all": props.apply_font_size_to_all,
- "apply_newline_to_all": props.apply_newline_to_all,
- "font_size": props.font_size,
- "newline_at": props.newline_at,
- "literals": [],
- }
-
- for i, literal in enumerate(props.literals):
- literal_data = {
- "attributes": [
- (attr.string_value, attr.enum_value if attr.data_type == "enum" else attr.string_value)
- for attr in literal.attributes
- ],
- "box_alignment": literal.box_alignment[:] if hasattr(literal, "box_alignment") else None,
- "element_value_rows": [
- {
- "category": row.category,
- "element_key": row.element_key,
- "formatted_value": row.formatted_value,
- "separator": row.separator,
- }
- for row in literal.element_value_rows
- ],
- "product_used": literal.product_used.name if literal.product_used else None,
- }
-
- if i < len(props.literal_apply_settings):
- apply_settings = props.literal_apply_settings[i]
- literal_data["apply_text_to_all"] = apply_settings.apply_text_to_all
- literal_data["apply_path_to_all"] = apply_settings.apply_path_to_all
- literal_data["apply_box_alignment_to_all"] = apply_settings.apply_box_alignment_to_all
- else:
- literal_data["apply_text_to_all"] = False
- literal_data["apply_path_to_all"] = False
- literal_data["apply_box_alignment_to_all"] = False
-
- captured_apply_settings["literals"].append(literal_data)
-
- obj["_bonsai_element_value_rows_backup"] = json.dumps(captured_apply_settings["literals"])
-
- core.edit_text(tool.Drawing, obj=obj)
-
- self.apply_to_selected_objects_with_captured_data(context, obj, captured_apply_settings)
-
+ core.edit_text(tool.Drawing, obj=tool.Blender.get_active_object())
tool.Blender.update_viewport()
- return {"FINISHED"}
- def apply_to_selected_objects(self, context, active_obj, active_props):
- """Apply changes to other selected text objects based on toggle settings"""
- selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
+class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.copy_text_to_selection"
+ bl_label = "Copy Text To Selection"
+ bl_description = "Copy text formatting or literals to selected objects"
+ bl_options = {"REGISTER", "UNDO"}
+ attribute: bpy.props.StringProperty()
- for obj in selected_objects:
- element = tool.Ifc.get_entity(obj)
- if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
- continue
-
- obj_props = tool.Drawing.get_text_props(obj)
- needs_update = False
-
- if active_props.apply_font_size_to_all:
- obj_props.font_size = active_props.font_size
- needs_update = True
-
- if active_props.apply_newline_to_all:
- obj_props.newline_at = active_props.newline_at
- needs_update = True
-
- for i, active_literal in enumerate(active_props.literals):
- if i >= len(obj_props.literals):
- continue
-
- obj_props.ensure_literal_apply_settings(len(obj_props.literals))
- obj_literal = obj_props.literals[i]
-
- if i < len(active_props.literal_apply_settings):
- active_settings = active_props.literal_apply_settings[i]
-
- if active_settings.apply_text_to_all:
- if len(active_literal.attributes) > 0 and len(obj_literal.attributes) > 0:
- obj_literal.attributes[0].string_value = active_literal.attributes[0].string_value
- needs_update = True
-
- if active_settings.apply_path_to_all:
- if len(active_literal.attributes) > 1 and len(obj_literal.attributes) > 1:
- if (
- active_literal.attributes[1].data_type == "enum"
- and obj_literal.attributes[1].data_type == "enum"
- ):
- obj_literal.attributes[1].enum_value = active_literal.attributes[1].enum_value
- else:
- obj_literal.attributes[1].string_value = active_literal.attributes[1].string_value
- needs_update = True
-
- if active_settings.apply_box_alignment_to_all:
- obj_literal.box_alignment = active_literal.box_alignment[:]
- needs_update = True
-
- if needs_update:
- core.edit_text(tool.Drawing, obj=obj)
-
- def apply_to_selected_objects_with_captured_data(self, context, active_obj, captured_data):
- """Apply changes to other selected text objects using captured apply settings"""
- selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
-
- for obj in selected_objects:
- element = tool.Ifc.get_entity(obj)
- if not element:
- continue
- if not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
- continue
-
- obj_props = tool.Drawing.get_text_props(obj)
-
- if len(obj_props.literals) == 0:
- core.enable_editing_text(tool.Drawing, obj=obj)
- obj_props.ensure_literal_apply_settings(len(obj_props.literals))
-
- needs_update = False
-
- if captured_data["apply_font_size_to_all"]:
- obj_props.font_size = captured_data["font_size"]
- needs_update = True
-
- if captured_data["apply_newline_to_all"]:
- obj_props.newline_at = captured_data["newline_at"]
- needs_update = True
-
- for i, captured_literal in enumerate(captured_data["literals"]):
- if i >= len(obj_props.literals):
- continue
-
- obj_literal = obj_props.literals[i]
-
- if captured_literal["apply_text_to_all"]:
- if len(captured_literal["attributes"]) > 0 and len(obj_literal.attributes) > 0:
- new_value = captured_literal["attributes"][0][0] # [0] = string_value
- obj_literal.attributes[0].string_value = new_value
- needs_update = True
-
- if captured_literal["apply_path_to_all"]:
- if len(captured_literal["attributes"]) > 1 and len(obj_literal.attributes) > 1:
- new_value = captured_literal["attributes"][1][1] # [1] = enum_value or string_value
- if obj_literal.attributes[1].data_type == "enum":
- obj_literal.attributes[1].enum_value = new_value
- else:
- obj_literal.attributes[1].string_value = new_value
- needs_update = True
-
- if captured_literal["apply_box_alignment_to_all"] and captured_literal["box_alignment"]:
- obj_literal.box_alignment = captured_literal["box_alignment"]
- needs_update = True
-
- if needs_update:
- core.edit_text(tool.Drawing, obj=obj)
+ def _execute(self, context):
+ apply_objs = [
+ obj
+ for obj in tool.Blender.get_selected_objects()
+ if (element := tool.Ifc.get_entity(obj))
+ and tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"])
+ ]
+ core.copy_text_to_selection(
+ tool.Drawing,
+ attribute=self.attribute,
+ attribute_obj=tool.Blender.get_active_object(),
+ apply_objs=apply_objs,
+ )
+ tool.Blender.update_viewport()
class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
@@ -3358,8 +3223,6 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
props = tool.Drawing.get_text_props(obj)
core.enable_editing_text(tool.Drawing, obj=obj)
- props.ensure_literal_apply_settings(len(props.literals))
-
text_element = tool.Ifc.get_entity(obj)
assigned_product_entity = tool.Drawing.get_assigned_product(text_element) if text_element else None
assigned_product_obj = tool.Ifc.get_object(assigned_product_entity) if assigned_product_entity else None
@@ -3448,9 +3311,6 @@ class AddTextLiteral(bpy.types.Operator):
box_alignment_mask = [False] * 9
box_alignment_mask[6] = True # bottom_left box_alignment
literal_props.box_alignment = box_alignment_mask
-
- props.ensure_literal_apply_settings(len(props.literals))
-
return {"FINISHED"}
@@ -3469,9 +3329,6 @@ class RemoveTextLiteral(bpy.types.Operator):
props = tool.Drawing.get_text_props(obj)
props.literals.remove(self.literal_prop_id)
tool.Blender.update_viewport()
-
- props.ensure_literal_apply_settings(len(props.literals))
-
return {"FINISHED"}
@@ -5056,7 +4913,7 @@ class ShowCategoryHelp(bpy.types.Operator):
class AddElementValueRow(bpy.types.Operator):
bl_idname = "bim.add_element_value_row"
- bl_label = "Add Element"
+ bl_label = "Add Element Value Row"
bl_description = "Add a new element value row"
bl_options = {"REGISTER", "UNDO"}
diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py
index 3b7040bc02..b323b8d41e 100644
--- a/src/bonsai/bonsai/bim/module/drawing/prop.py
+++ b/src/bonsai/bonsai/bim/module/drawing/prop.py
@@ -872,22 +872,20 @@ class LiteralProps(PropertyGroup):
category_for_adding: str
-class LiteralApplySettings(PropertyGroup):
- literal_index: IntProperty(name="Literal Index")
- apply_text_to_all: BoolProperty(name="Apply Text to All", default=False)
- apply_path_to_all: BoolProperty(name="Apply Path to All", default=False)
- apply_box_alignment_to_all: BoolProperty(name="Apply Box Alignment to All", default=False)
-
- if TYPE_CHECKING:
- literal_index: int
- apply_text_to_all: bool
- apply_path_to_all: bool
- apply_box_alignment_to_all: bool
-
-
class BIMTextProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
literals: CollectionProperty(name="Literals", type=LiteralProps)
+ newline_at: IntProperty(name="Newline At")
+ symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
+ name="Symbol",
+ description="Symbol from symbols.svg to use for this text.",
+ items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
+ default="NO SYMBOL",
+ )
+ custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
+ name="Custom Symbol",
+ description="Non-default symbol to use for this text.",
+ )
font_size: EnumProperty(
items=[
("1.8", "1.8 - Small", ""),
@@ -899,52 +897,34 @@ class BIMTextProperties(PropertyGroup):
default="2.5",
name="Font Size",
)
- newline_at: IntProperty(name="Newline At")
- reverse_list: BoolProperty(name="Reverse List", description="Reverses the order of any list.", default=False)
- list_separator: StringProperty( # pyright: ignore[reportRedeclaration]
- name="List Separator",
- description="Text used to separate lists. Uses a comma (, ) if empty.",
+ align_horizontal: EnumProperty(
+ items=[
+ ("left", "Left", "", "ALIGN_LEFT", 0),
+ ("middle", "Middle", "", "ALIGN_CENTER", 1),
+ ("right", "Right", "", "ALIGN_RIGHT", 2),
+ ],
+ default="left",
+ name="Horizontal Alignment",
)
- symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
- name="Symbol",
- description="Symbol from symbols.svg to use for this text.",
- items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
- default="NO SYMBOL",
+ align_vertical: EnumProperty(
+ items=[
+ ("top", "Top", "", "ALIGN_TOP", 0),
+ ("middle", "Middle", "", "ALIGN_MIDDLE", 1),
+ ("bottom", "Bottom", "", "ALIGN_BOTTOM", 2),
+ ],
+ default="middle",
+ name="Vertical Alignment",
)
- custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
- name="Custom Symbol",
- description="Non-default symbol to use for this text.",
- )
-
- apply_font_size_to_all: BoolProperty(
- name="Apply Font Size to All", description="Apply font size changes to all selected text objects", default=False
- )
- apply_newline_to_all: BoolProperty(
- name="Apply Newline to All", description="Apply newline changes to all selected text objects", default=False
- )
-
- literal_apply_settings: CollectionProperty(name="Literal Apply Settings", type=LiteralApplySettings)
-
- def ensure_literal_apply_settings(self, literal_count: int):
- """Ensure we have apply settings for all literals"""
- while len(self.literal_apply_settings) > literal_count:
- self.literal_apply_settings.remove(len(self.literal_apply_settings) - 1)
-
- while len(self.literal_apply_settings) < literal_count:
- setting = self.literal_apply_settings.add()
- setting.literal_index = len(self.literal_apply_settings) - 1
if TYPE_CHECKING:
is_editing: bool
literals: bpy.types.bpy_prop_collection_idprop[LiteralProps]
- font_size: str
newline_at: int
- reverse_list: bool
- list_separator: str
symbol: Union[str, Literal["NO SYMBOL", "CUSTOM SYMBOL"]]
custom_symbol: str
- apply_font_size_to_all: bool
- apply_newline_to_all: bool
+ font_size: str
+ align_horizontal: str
+ align_vertical: str
def get_symbol(self) -> Union[str, None]:
if self.symbol == "NO SYMBOL":
@@ -979,8 +959,6 @@ class BIMTextProperties(PropertyGroup):
"FontSize": float(self.font_size),
"Newline_At": int(self.newline_at),
"Symbol": self.get_symbol(),
- "Reverse_List": self.reverse_list,
- "List_Separator": self.list_separator or ", ",
}
return text_data
diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py
index dac8e54a61..ef64fdefc6 100644
--- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py
+++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py
@@ -989,11 +989,6 @@ class SvgWriter:
symbol = tool.Drawing.get_annotation_symbol(element)
newline_at = tool.Drawing.get_newline_at(element)
- # Get reverse_list and list_separator from EPset_Annotation
- pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {}
- reverse_list = pset_data.get("Reverse_List", False)
- list_separator = pset_data.get("List_Separator") or ", "
-
template_text_fields = []
if symbol:
symbol_transform = self.get_symbol_transform(text_position_svg_str, angle, text_obj)
@@ -1010,7 +1005,7 @@ class SvgWriter:
# NOTE: zip makes sure that we iterate over the shortest list
for field, text_literal in zip(template_text_fields, text_literals):
field.text = tool.Drawing.replace_text_literal_variables(
- text_literal.Literal, product or element, reverse_list, list_separator
+ text_literal.Literal, product or element
)
field.attrib["class"] = classes_str
@@ -1030,9 +1025,7 @@ class SvgWriter:
line_number = 0
for text_literal in text_literals:
- text = tool.Drawing.replace_text_literal_variables(
- text_literal.Literal, product or element, reverse_list, list_separator
- )
+ text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product or element)
text_segments = parse_markdown_it(text)
diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py
index 45ae50beb4..556459b8b6 100644
--- a/src/bonsai/bonsai/bim/module/drawing/ui.py
+++ b/src/bonsai/bonsai/bim/module/drawing/ui.py
@@ -644,58 +644,56 @@ class BIM_PT_text(Panel):
row.operator("bim.add_text_literal", icon="ADD", text="Add Literal")
else:
row.operator("bim.edit_text", icon="CHECKMARK")
- row.operator("bim.add_text_literal", icon="ADD", text="")
row.operator("bim.disable_editing_text", icon="CANCEL", text="")
row = self.layout.row(align=True)
row.prop(props, "font_size")
- row.prop(props, "apply_font_size_to_all", text="", icon="COPYDOWN")
+ row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "FONT_SIZE"
+ row = self.layout.row()
+ row.label(text="Alignment")
+ row.prop(props, "align_horizontal", text="", expand=True)
+ row.prop(props, "align_vertical", text="", expand=True)
+ row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "ALIGNMENT"
row = self.layout.row(align=True)
row.prop(props, "newline_at")
- row = self.layout.row(align=True)
- row.prop(props, "reverse_list")
- row = self.layout.row(align=True)
- row.prop(props, "list_separator")
+ row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "WRAP_LENGTH"
row = self.layout.row(align=True)
row.prop(props, "symbol")
if props.symbol == "CUSTOM SYMBOL":
row = self.layout.row(align=True)
row.prop(props, "custom_symbol", text="")
- row.prop(props, "apply_newline_to_all", text="", icon="COPYDOWN")
select_op = row.operator("bim.select_similar_text_literal_value", text="", icon="RESTRICT_SELECT_OFF")
select_op.literal_value = str(props.newline_at)
select_op.attribute_type = "newline"
+ row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "SYMBOL"
+
+ row = self.layout.row(align=True)
+ row.label(text="Literals:")
+ row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "LITERALS"
+ row.operator("bim.add_text_literal", icon="ADD", text="")
for i, literal_props in enumerate(props.literals):
box = self.layout.box()
-
- row = box.row(align=True)
- row.label(text=f"Literal[{i}]:")
- if i > 0:
- row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
- if i < len(props.literals) - 1:
- row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
- row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
-
- if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings):
+ if len(literal_props.attributes):
row = box.row(align=True)
bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True)
+ if i > 0:
+ row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
+ if i < len(props.literals) - 1:
+ row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
+ row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW"
op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="")
op.literal_prop_id = i
- row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN")
-
element = tool.Ifc.get_entity(obj)
assigned_element = tool.Drawing.get_assigned_product(element) or element
resolved_value = tool.Drawing.replace_text_literal_variables(
literal_props.attributes[0].string_value,
assigned_element,
- props.reverse_list,
- props.list_separator,
)
row = box.row(align=True)
row.label(text="CurrentValue:")
@@ -779,8 +777,6 @@ class BIM_PT_text(Panel):
else:
row.prop(attr, "string_value", text="Path")
select_value = attr.string_value
- if i < len(props.literal_apply_settings):
- row.prop(props.literal_apply_settings[i], "apply_path_to_all", text="", icon="COPYDOWN")
other_attributes = [a for a in literal_props.attributes[2:] if a.name != "BoxAlignment"]
if other_attributes:
@@ -800,10 +796,6 @@ class BIM_PT_text(Panel):
col = row.column(align=True)
alignment_label_row = col.row(align=True)
alignment_label_row.label(text=" Text box alignment:")
- if i < len(props.literal_apply_settings):
- alignment_label_row.prop(
- props.literal_apply_settings[i], "apply_box_alignment_to_all", text="", icon="COPYDOWN"
- )
box_alignment_value = (
literal_props.attributes[
@@ -824,61 +816,52 @@ class BIM_PT_text(Panel):
props = tool.Drawing.get_text_props(obj)
if props.is_editing:
- self.draw_text_editing_ui(context)
- else:
- text_data = DecoratorData.get_text_data(obj)
+ return self.draw_text_editing_ui(context)
+ text_data = DecoratorData.get_text_data(obj)
- row = self.layout.row()
- row.operator("bim.enable_editing_text", icon="GREASEPENCIL")
+ row = self.layout.row()
+ row.operator("bim.enable_editing_text", icon="GREASEPENCIL")
- row = self.layout.row(align=True)
- row.label(text="FontSize")
- click_op = row.operator(
- "bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False
- )
- click_op.literal_value = str(text_data["FontSize"])
- click_op.attribute_type = "font_size"
- click_op.display_text = str(text_data["FontSize"])
+ row = self.layout.row(align=True)
+ row.label(text="FontSize")
+ click_op = row.operator("bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False)
+ click_op.literal_value = str(text_data["FontSize"])
+ click_op.attribute_type = "font_size"
+ click_op.display_text = str(text_data["FontSize"])
- row = self.layout.row(align=True)
- row.label(text="Newline_At")
- click_op = row.operator(
- "bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
- )
- click_op.literal_value = str(text_data["Newline_At"])
- click_op.attribute_type = "newline"
- click_op.display_text = str(text_data["Newline_At"])
- row = self.layout.row(align=True)
- row.label(text="Reverse_List")
- row.label(text=str(text_data["Reverse_List"]))
- row = self.layout.row(align=True)
- row.label(text="List_Separator")
- row.label(text=str(text_data["List_Separator"]))
+ row = self.layout.row(align=True)
+ row.label(text="Newline_At")
+ click_op = row.operator(
+ "bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
+ )
+ click_op.literal_value = str(text_data["Newline_At"])
+ click_op.attribute_type = "newline"
+ click_op.display_text = str(text_data["Newline_At"])
- for i, literal_data in enumerate(text_data["Literals"]):
- box = self.layout.box()
- box.label(text=f"Literal[{i}]:")
+ 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:
- row = box.row(align=True)
- row.label(text=attribute)
- click_op = row.operator(
- "bim.select_similar_text_literal_value",
- text=str(literal_data[attribute]),
- emboss=False,
- )
- click_op.literal_value = str(literal_data[attribute])
- click_op.literal_index = i
- if attribute == "Literal":
- click_op.attribute_type = "literal"
- elif attribute == "Path":
- click_op.attribute_type = "path"
- elif attribute == "BoxAlignment":
- click_op.attribute_type = "box_alignment"
- else:
- click_op.attribute_type = "text"
- click_op.display_text = str(literal_data[attribute])
+ # Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
+ for attribute in literal_data:
+ row = box.row(align=True)
+ row.label(text=attribute)
+ click_op = row.operator(
+ "bim.select_similar_text_literal_value",
+ text=str(literal_data[attribute]),
+ emboss=False,
+ )
+ click_op.literal_value = str(literal_data[attribute])
+ click_op.literal_index = i
+ if attribute == "Literal":
+ click_op.attribute_type = "literal"
+ elif attribute == "Path":
+ click_op.attribute_type = "path"
+ elif attribute == "BoxAlignment":
+ click_op.attribute_type = "box_alignment"
+ else:
+ click_op.attribute_type = "text"
+ click_op.display_text = str(literal_data[attribute])
class BIM_UL_drawinglist(bpy.types.UIList):
diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py
index 15f3052935..fbd694d219 100644
--- a/src/bonsai/bonsai/bim/module/geometry/operator.py
+++ b/src/bonsai/bonsai/bim/module/geometry/operator.py
@@ -1730,7 +1730,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
tool.Collector,
tool.Spatial,
container=original_data[matching_group_id][index]["Container"],
- element_obj=obj,
+ objs=[obj],
)
for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)):
tool.Collector.assign(tool.Ifc.get_object(part))
diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index 57b4b561d0..9fbd631003 100644
--- a/src/bonsai/bonsai/bim/module/model/__init__.py
+++ b/src/bonsai/bonsai/bim/module/model/__init__.py
@@ -50,9 +50,10 @@ classes = (
array.EditArray,
array.EnableEditingArray,
array.ApplyArray,
+ array.RegenerateArray,
array.RemoveArray,
- array.SelectArrayParent,
array.SelectAllArrayObjects,
+ array.SelectArrayParent,
array.Input3DCursorXArray,
array.Input3DCursorYArray,
array.Input3DCursorZArray,
diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py
index 3fb9360256..5f045435ac 100644
--- a/src/bonsai/bonsai/bim/module/model/array.py
+++ b/src/bonsai/bonsai/bim/module/model/array.py
@@ -61,7 +61,6 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator):
"y": 0.0,
"z": 0.0,
"use_local_space": True,
- "sync_children": False,
"method": "OFFSET",
}
@@ -80,28 +79,27 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator):
pset=pset,
properties={"Parent": element.GlobalId, "Data": ifc_file.create_entity("IfcText", json.dumps(data))},
)
- return {"FINISHED"}
-class DisableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
+class DisableEditingArray(bpy.types.Operator):
bl_idname = "bim.disable_editing_array"
bl_label = "Disable Editing Array"
bl_options = {"REGISTER", "UNDO"}
- def _execute(self, context):
+ def execute(self, context):
obj = context.active_object
assert obj
tool.Model.get_array_props(obj).is_editing = -1
return {"FINISHED"}
-class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
+class EnableEditingArray(bpy.types.Operator):
bl_idname = "bim.enable_editing_array"
bl_label = "Enable Editing Array"
bl_options = {"REGISTER", "UNDO"}
item: bpy.props.IntProperty()
- def _execute(self, context):
+ def execute(self, context):
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -122,11 +120,9 @@ class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
props.y = data["y"] * si_conversion
props.z = data["z"] * si_conversion
props.use_local_space = data.get("use_local_space", False)
- props.sync_children = data.get("sync_children", False)
props.method = data.get("method", "OFFSET")
props.is_editing = self.item
-
return {"FINISHED"}
@@ -151,31 +147,25 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator):
"y": props.y / si_conversion,
"z": props.z / si_conversion,
"use_local_space": props.use_local_space,
- "sync_children": props.sync_children,
"method": props.method,
}
props.is_editing = -1
try:
- parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"]))
+ parent_element = tool.Ifc.get().by_guid(pset["Parent"])
+ parent = tool.Ifc.get_object(parent_element)
except:
return {"FINISHED"}
+ tool.Blender.Modifier.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data)
-
- pset = tool.Ifc.get().by_id(pset["id"])
- data = tool.Ifc.get().createIfcText(json.dumps(data))
- ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
-
tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
# clears the relating_array_object so it doesn't show again next time
props.relating_array_object = None
- return {"FINISHED"}
-
class ApplyArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.apply_array"
@@ -192,6 +182,33 @@ class ApplyArray(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
+class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.regenerate_array"
+ bl_label = "Regenerate Array"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def _execute(self, context):
+ obj = context.active_object
+ element = tool.Ifc.get_entity(obj)
+ pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
+ try:
+ parent_element = tool.Ifc.get().by_guid(pset["Parent"])
+ parent = tool.Ifc.get_object(parent_element)
+ except:
+ return {"FINISHED"}
+ pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
+ arrays = json.loads(pset["Data"])
+ pset = tool.Ifc.get().by_id(pset["id"])
+ for array in arrays:
+ for child in set(array["children"]):
+ if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
+ tool.Geometry.delete_ifc_object(child_obj)
+ array["children"].clear()
+ print("cleared array", arrays)
+ tool.Model.regenerate_array(obj, arrays)
+ tool.Blender.Modifier.Array.constrain_children_to_parent(element)
+
+
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_array"
bl_label = "Remove Array"
@@ -216,7 +233,8 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
props.is_editing = -1
try:
- parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"]))
+ parent_element = tool.Ifc.get().by_guid(pset["Parent"])
+ parent = tool.Ifc.get_object(parent_element)
except:
return {"FINISHED"}
@@ -226,9 +244,10 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
if not self.keep_objs:
data[self.item]["count"] = 1
+ tool.Blender.Modifier.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
- pset = tool.Ifc.get().by_id(pset["id"])
+ pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
@@ -237,8 +256,6 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
- return {"FINISHED"}
-
class SelectArrayParent(bpy.types.Operator):
bl_idname = "bim.select_array_parent"
diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py
index 8ca5712116..012cba4889 100644
--- a/src/bonsai/bonsai/bim/module/model/product.py
+++ b/src/bonsai/bonsai/bim/module/model/product.py
@@ -470,7 +470,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator):
parent = ifcopenshell.util.element.get_container(building_element)
if parent:
bonsai.core.spatial.assign_container(
- tool.Ifc, tool.Collector, tool.Spatial, container=parent, element_obj=obj
+ tool.Ifc, tool.Collector, tool.Spatial, container=parent, objs=[obj]
)
# set occurrences properties for the types defined with modifiers
@@ -493,7 +493,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator):
else:
if self.container_obj:
bonsai.core.spatial.assign_container(
- tool.Ifc, tool.Collector, tool.Spatial, container=self.container, element_obj=obj
+ tool.Ifc, tool.Collector, tool.Spatial, container=self.container, objs=[obj]
)
if props.rl_mode == "BOTTOM":
obj.location.z = self.container_obj.location.z - tool.Blender.get_object_bounding_box(obj)["min_z"]
diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py
index 9271f2acc1..572898c484 100644
--- a/src/bonsai/bonsai/bim/module/model/profile.py
+++ b/src/bonsai/bonsai/bim/module/model/profile.py
@@ -1131,8 +1131,13 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
MEPGenerator().setup_ports(profile1["obj"])
else:
+ connect_IfcFlowSegments = tool.Ifc.get_entity(profiles[0]["obj"]).is_a("IfcFlowSegment")
for profile1, profile2 in zip(profiles[:-1], profiles[1:]):
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
+ if connect_IfcFlowSegments:
+ bpy.ops.bim.mep_connect_elements(
+ obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
+ )
def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py
index 9f2664190d..ec79a49196 100644
--- a/src/bonsai/bonsai/bim/module/model/prop.py
+++ b/src/bonsai/bonsai/bim/module/model/prop.py
@@ -387,11 +387,6 @@ class BIMArrayProperties(PropertyGroup):
name="Method",
default="OFFSET",
)
- sync_children: bpy.props.BoolProperty(
- name="Sync Children",
- description="Regenerate all children based on the parent object",
- default=False,
- )
relating_array_object: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Copy Array Properties",
diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py
index 509d24a60c..602c36df11 100644
--- a/src/bonsai/bonsai/bim/module/model/slab.py
+++ b/src/bonsai/bonsai/bim/module/model/slab.py
@@ -231,17 +231,16 @@ class DumbSlabPlaner:
for inverse in tool.Ifc.get().get_inverse(layer_set):
if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3":
continue
-
if tool.Ifc.get().schema == "IFC2X3":
for rel in tool.Ifc.get().get_inverse(inverse):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
for element in rel.RelatedObjects:
- self.change_thickness(element, total_thickness, preserve_offset=True)
+ self.change_thickness(element, total_thickness)
else:
for rel in inverse.AssociatedTo:
for element in rel.RelatedObjects:
- self.change_thickness(element, total_thickness, preserve_offset=True)
+ self.change_thickness(element, total_thickness)
def regenerate_from_occurence(self, element, material_set_usage):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -251,12 +250,10 @@ class DumbSlabPlaner:
return
self.change_thickness(element, total_thickness)
- def change_thickness(
- self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False
- ) -> None:
+ def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None:
+ self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
if tool.Model.get_usage_type(element) != "LAYER3":
return
-
layer_params = tool.Model.get_material_layer_parameters(element)
ifc_file = tool.Ifc.get()
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
@@ -274,48 +271,72 @@ class DumbSlabPlaner:
if representation:
extrusion = tool.Model.get_extrusion(representation)
if extrusion:
+ # TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction.
+ # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
+ # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
+ # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
+ existing_x_angle = obj.rotation_euler.x
+ existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
+ existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
+ existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
+ offset_direction = direction_ratios.copy()
+ perpendicular_depth = thickness * abs(1 / cos(existing_x_angle))
+ perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale
- # Calculate the actual extrusion angle from vertical
- extrusion_angle = 0
- if direction_ratios.length > 0:
- cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1)))
- extrusion_angle = acos(min(max(cos_angle, -1), 1))
-
- # Only apply 1/cos factor when there's actual extrusion slope
- if extrusion_angle > 1e-6:
- perpendicular_depth = thickness * abs(1 / cos(extrusion_angle))
- perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle))
- else:
- perpendicular_depth = thickness
- perpendicular_offset = layer_offset
+ # Check angle and z direction to determine whether the extrusion direction is positive or negative
+ if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or (
+ abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0
+ ):
+ # The extrusion direction is positive. If the layer_parameter is set to negative,
+ # then the we change the extrusion direction.
+ if layer_params["direction_sense"] == "NEGATIVE":
+ direction_ratios *= -1
+ elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or (
+ abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0
+ ):
+ # The extrusion direction is negative. If the layer_parameter is set to positive,
+ # then the we change the extrusion direction. And the offset direction should remain positive
+ # for either direction sense, so we change it.
+ offset_direction *= -1
+ if layer_params["direction_sense"] == "POSITIVE":
+ direction_ratios *= -1
+ extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
- # Update position
ifc_position = extrusion.Position
+ position = offset_direction * perpendicular_offset
+ material = ifcopenshell.util.element.get_material(element)
+ if material:
+ if material.is_a("IfcMaterialLayerSetUsage"):
+ material.OffsetFromReferenceLine = position.z
+ if ifc_position:
+ ifc_position.Location.Coordinates = position
+ else:
+ tool.Model.add_extrusion_position(extrusion, position)
- if direction_ratios.length > 0:
- offset_vector = direction_ratios.normalized() * perpendicular_offset
- position = offset_vector
-
- material = ifcopenshell.util.element.get_material(element)
- if material and material.is_a("IfcMaterialLayerSetUsage"):
- # Only set offset if not preserving it (preserves independent offsets per instance)
- if not preserve_offset:
- material.OffsetFromReferenceLine = position.z
-
- if ifc_position:
- ifc_position.Location.Coordinates = position
- else:
- tool.Model.add_extrusion_position(extrusion, position)
-
- bonsai.core.geometry.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=representation,
- )
+ else:
+ props = tool.Model.get_model_props()
+ x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
+ new_rep = ifcopenshell.api.geometry.add_slab_representation(
+ tool.Ifc.get(),
+ context=body_context,
+ depth=thickness * self.unit_scale,
+ x_angle=x_angle,
+ )
+ for inverse in tool.Ifc.get().get_inverse(representation):
+ ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep)
+ bonsai.core.geometry.switch_representation(
+ tool.Ifc,
+ tool.Geometry,
+ obj=obj,
+ representation=new_rep,
+ )
+ bonsai.core.geometry.remove_representation(
+ tool.Ifc, tool.Geometry, obj=obj, representation=representation
+ )
+ return
else:
props = tool.Model.get_model_props()
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
@@ -328,118 +349,13 @@ class DumbSlabPlaner:
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=element, representation=representation
)
- bonsai.core.geometry.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=representation,
- )
- def update_extrusion_direction(
- element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None
- ) -> None:
- """
- Update extrusion direction while preserving overall object orientation.
-
- Args:
- element: The IFC element
- new_direction_ratios: New extrusion direction ratios (x,y,z)
- obj: Optional Blender object (will be fetched if not provided)
- """
- if not obj:
- obj = tool.Ifc.get_object(element)
- if not obj:
- return
-
- representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
- if not representation:
- return
-
- extrusion = tool.Model.get_extrusion(representation)
- if not extrusion:
- return
-
- # Get current extrusion direction
- old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
- if old_direction.length == 0:
- old_direction = Vector((0, 0, 1)) # Default
-
- new_direction = Vector(new_direction_ratios)
- if new_direction.length == 0:
- new_direction = Vector((0, 0, 1)) # Default
-
- # Normalize both directions
- old_direction_normalized = old_direction.normalized()
- new_direction_normalized = new_direction.normalized()
-
- # Store current object matrix
- old_matrix = obj.matrix_world.copy()
-
- # Calculate the rotation needed to keep same orientation
- # When extrusion direction changes from A to B relative to local coordinates,
- # we need to rotate the object by the inverse of that change
-
- # Calculate rotation from old to new direction
- rotation_axis = old_direction_normalized.cross(new_direction_normalized)
- if rotation_axis.length > 1e-6:
- rotation_axis.normalized()
- dot_product = old_direction_normalized.dot(new_direction_normalized)
- angle = acos(min(max(dot_product, -1), 1))
-
- # Apply INVERSE rotation to object to compensate
- rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis)
-
- # Update object rotation
- obj.matrix_world = old_matrix @ rotation_matrix
- bpy.context.view_layer.update()
-
- # Update extrusion direction (keeping magnitude)
- if old_direction.length > 0:
- # Preserve the magnitude of the original direction vector
- magnitude = old_direction.length
- new_direction = new_direction_normalized * magnitude
-
- extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction)
-
- # Update depth based on new extrusion angle
- extrusion_angle = 0
- if new_direction.length > 0:
- cos_angle = new_direction_normalized.dot(Vector((0, 0, 1)))
- extrusion_angle = acos(min(max(cos_angle, -1), 1))
-
- # Get current depth (perpendicular depth)
- current_perpendicular_depth = extrusion.Depth
-
- # If we have material layer info, calculate actual thickness
- material = ifcopenshell.util.element.get_material(element)
- actual_thickness = current_perpendicular_depth
- if material and material.is_a("IfcMaterialLayerSetUsage"):
- layer_set = material.ForLayerSet
- actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers])
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
- actual_thickness *= unit_scale
-
- # Convert to perpendicular depth if needed
- if extrusion_angle > 1e-6:
- new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle))
- else:
- new_perpendicular_depth = actual_thickness
-
- extrusion.Depth = new_perpendicular_depth
-
- # Update position offset if needed
- if extrusion.Position:
- # Recalculate offset based on new direction
- material = ifcopenshell.util.element.get_material(element)
- if material and material.is_a("IfcMaterialLayerSetUsage"):
- offset = material.OffsetFromReferenceLine
- if extrusion_angle > 1e-6:
- perpendicular_offset = offset * abs(1 / cos(extrusion_angle))
- else:
- perpendicular_offset = offset
-
- offset_vector = new_direction_normalized * perpendicular_offset
- extrusion.Position.Location.Coordinates = tuple(offset_vector)
+ bonsai.core.geometry.switch_representation(
+ tool.Ifc,
+ tool.Geometry,
+ obj=obj,
+ representation=representation,
+ )
class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
@@ -716,8 +632,6 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
layer_params = tool.Model.get_material_layer_parameters(element)
- usage_type = tool.Model.get_usage_type(element)
-
# TODO: review #7537 properly, this is a quick fix but something doesn't seem right.
original_rotation_x = 0
if extrusion.Position:
@@ -732,49 +646,22 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tranlation_matrix = Matrix.Translation(rot_offset)
position = position @ tranlation_matrix
- # For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal
- if usage_type == "LAYER3":
- # Store original rotation for later restoration
- original_rotation_x = obj.rotation_euler.x
- obj["pre_edit_rotation_x"] = original_rotation_x
-
- # Reset rotation to zero - profile will be horizontal
- current_z_rot = obj.rotation_euler.z
- obj.rotation_euler.x = 0.0
- obj.rotation_euler.z = current_z_rot
- else:
- # Original behavior: Restore Object rotation to zero
- local_rot_mat = obj.rotation_euler.to_matrix()
- rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
- new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
- new_rot_euler = new_rot_mat.to_euler()
- obj.rotation_euler = new_rot_euler
+ # Restore Object rotation to zero
+ local_rot_mat = obj.rotation_euler.to_matrix()
+ rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
+ new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
+ new_rot_euler = new_rot_mat.to_euler()
+ obj.rotation_euler = new_rot_euler
else:
position = Matrix()
- # Import profile with correct x_angle
- if usage_type == "LAYER3":
- # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection
- obj_x_rotation = original_rotation_x # Use stored original rotation
- scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
-
- # Import with x_angle=0
- tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0)
-
- # Scale the Y coordinates by cos(rotation) to get horizontal projection
- bpy.ops.object.mode_set(mode="OBJECT")
- for vert in obj.data.vertices:
- vert.co.y *= scale_factor
- else:
- # For other types: Use existing_x_angle
- tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
+ tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
bpy.ops.object.mode_set(mode="EDIT")
ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context))
if not bpy.app.background:
tool.Blender.set_viewport_tool("bim.cad_tool")
-
return {"FINISHED"}
@@ -796,7 +683,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
extrusion = tool.Model.get_extrusion(body)
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
layer_params = tool.Model.get_material_layer_parameters(element)
- usage_type = tool.Model.get_usage_type(element)
if extrusion.Position:
position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist())
@@ -810,38 +696,17 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tranlation_matrix = Matrix.Translation(rot_offset)
position = position @ tranlation_matrix
- # Restore rotation
- if usage_type == "LAYER3":
- # Restore original rotation from before editing
- if "pre_edit_rotation_x" in obj:
- current_z_rot = obj.rotation_euler.z
- obj.rotation_euler.x = obj["pre_edit_rotation_x"]
- obj.rotation_euler.z = current_z_rot
- del obj["pre_edit_rotation_x"]
- else:
- # Original behavior
- local_rot_mat = obj.rotation_euler.to_matrix()
- rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
- new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
- new_rot_euler = new_rot_mat.to_euler()
- obj.rotation_euler = new_rot_euler
+ # Restore Object rotation to x_angle
+ local_rot_mat = obj.rotation_euler.to_matrix()
+ rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
+ new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
+ new_rot_euler = new_rot_mat.to_euler()
+ obj.rotation_euler = new_rot_euler
else:
position = Matrix()
- # Export profile with correct x_angle
- if usage_type == "LAYER3":
- # Scale Y coordinates back up before exporting
- obj_x_rotation = obj.rotation_euler.x
- scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
-
- # Un-scale the profile before exporting
- for vert in obj.data.vertices:
- vert.co.y /= scale_factor # Inverse of import scaling
-
- profile = tool.Model.export_profile(obj, position=position, x_angle=0)
- else:
- profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
+ profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
if not profile:
@@ -893,28 +758,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tool.Ifc.get(), product=element, representation=new_footprint
)
- footprint_context = ifcopenshell.util.representation.get_context(
- tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW"
- )
- if not footprint_context:
- return
-
- curves = [profile.OuterCurve]
- if profile.is_a("IfcArbitraryProfileDefWithVoids"):
- curves.extend(profile.InnerCurves)
- new_footprint = ifcopenshell.api.geometry.add_footprint_representation(
- tool.Ifc.get(), context=footprint_context, curves=curves
- )
- old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW")
- if old_footprint:
- for inverse in tool.Ifc.get().get_inverse(old_footprint):
- ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint)
- bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint)
- else:
- ifcopenshell.api.geometry.assign_representation(
- tool.Ifc.get(), product=element, representation=new_footprint
- )
-
class ResetVertex(bpy.types.Operator):
bl_idname = "bim.reset_vertex"
diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py
index 2c931f4f45..d8a297433b 100644
--- a/src/bonsai/bonsai/bim/module/model/ui.py
+++ b/src/bonsai/bonsai/bim/module/model/ui.py
@@ -229,6 +229,7 @@ class BIM_PT_array(bpy.types.Panel):
if ArrayData.data["parameters"]:
row = self.layout.row(align=True)
row.label(text=ArrayData.data["parameters"]["parent_name"], icon="CON_CHILDOF")
+ row.operator("bim.regenerate_array", icon="FILE_REFRESH", text="")
row.operator("bim.select_array_parent", icon="OBJECT_DATA", text="")
row.operator("bim.select_all_array_objects", icon="RESTRICT_SELECT_OFF", text="")
@@ -246,7 +247,6 @@ class BIM_PT_array(bpy.types.Panel):
row.prop(props, "method")
row = box.row(align=True)
row.prop(props, "use_local_space")
- row.prop(props, "sync_children")
col = box.column()
row = col.row(align=True)
row.prop(props, "x")
diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py
index 5276e64692..ba3d93586b 100644
--- a/src/bonsai/bonsai/bim/module/model/wall.py
+++ b/src/bonsai/bonsai/bim/module/model/wall.py
@@ -403,42 +403,24 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
-
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
-
- # Get extrusion direction
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
-
- # Calculate angle from vertical
x_angle = Vector((0, 1)).angle_signed(Vector((y, z)))
-
- # For sloped walls, compensate so VERTICAL height = target depth
- cos_angle = cos(x_angle)
- compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0
- new_depth_ifc = (self.depth / si_conversion) * compensation_factor
-
- extrusion.Depth = new_depth_ifc
-
- # IMPORTANT: Refresh the geometry to reflect the IFC changes
- bonsai.core.geometry.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=representation,
- )
-
+ extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle))
if tool.Model.get_usage_type(element) == "LAYER2":
for rel in element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements":
- related_element = rel.RelatedElement
- if related_element.is_a() == "IfcWall":
- layer2_objs.append(tool.Ifc.get_object(related_element))
+ ifcopenshell.api.geometry.disconnect_element(
+ ifc_file,
+ relating_element=rel.RelatingElement,
+ related_element=element,
+ )
+ layer2_objs.append(obj)
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
-
return {"FINISHED"}
@@ -458,143 +440,81 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
layer2_objs: list[bpy.types.Object] = []
- builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
+ x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle
+ x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
- x_angle = self.x_angle
+ selected_objs = tool.Model.get_selected_mesh_ifc_objects()
+ builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
- for obj in context.selected_objects:
+ for obj in selected_objs:
element = tool.Ifc.get_entity(obj)
- if not element:
- continue
-
+ assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
-
- # Get current object rotation matrix
- obj_rotation = obj.matrix_world.to_3x3()
-
- # Get current extrusion direction in LOCAL coordinates
- current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
- if current_local_direction.length == 0:
- current_local_direction = Vector((0, 0, 1))
- current_local_direction_normalized = current_local_direction.normalized()
-
- # Calculate what the current extrusion direction is in WORLD coordinates
- current_world_direction = obj_rotation @ current_local_direction_normalized
-
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
-
- # Calculate the NEW local extrusion direction based on x_angle
- new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
-
- # Check if extrusion direction is actually changing
- current_local_norm = current_local_direction_normalized
- new_local_norm = new_local_direction.normalized()
-
- # Compare the LOCAL directions
- local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6
-
if tool.Model.get_usage_type(element) == "LAYER2":
+ x, y, z = extrusion.ExtrudedDirection.DirectionRatios
depth = extrusion.Depth / abs(1 / cos(existing_x_angle))
perpendicular_depth = depth * abs(1 / cos(x_angle))
-
- # Update extrusion direction
- if local_direction_changed:
- extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction)
-
- # Always update depth
- extrusion.Depth = perpendicular_depth
+ extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle))
layer2_objs.append(obj)
-
+ extrusion.Depth = perpendicular_depth
else:
if tool.Model.get_usage_type(element) == "LAYER3":
- # For slabs, handle polyline scaling
- existing_obj_x_angle = obj.rotation_euler.x
- existing_obj_x_angle = (
- 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle
- )
- existing_obj_x_angle = (
- 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle
- )
+ existing_x_angle = obj.rotation_euler.x
+ existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
+ existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
- # Scale the polyline coordinates
coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve)
coord_list = [
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
- ] # Reset the transformation
+ ] # Reset the transformation and returns to the original points with 0 degrees
coord_list = [
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
] # Apply the transformation for the new x_angle
builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list)
- # Calculate new extrusion direction with direction sense
- base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
+ # The extrusion direction calculated previously default to the positive direction
+ # Here we set the extrusion direction to negative if that's the case
+ direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle)))
+ # direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
layer_params = tool.Model.get_material_layer_parameters(element)
perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale
perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale
- offset_direction = base_local_direction.copy()
+ offset_direction = direction_ratios.copy()
- # Apply direction sense
- final_local_direction = base_local_direction.copy()
- if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or (
- abs(x_angle) > (pi / 2) and base_local_direction.z < 0
+ # Check angle and z direction to determine whether the extrusion direction is positive or negative
+ if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or (
+ abs(x_angle) > (pi / 2) and direction_ratios.z < 0
):
+ # The extrusion direction is positive. If the layer_parameter is set to negative,
+ # then the we change the extrusion direction.
if layer_params["direction_sense"] == "NEGATIVE":
- final_local_direction *= -1
- elif (x_angle > (pi / 2) and base_local_direction.z > 0) or (
- x_angle < (pi / 2) and base_local_direction.z < 0
+ direction_ratios *= -1
+ elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or (
+ (x_angle) < (pi / 2) and direction_ratios.z < 0
):
+ # The extrusion direction is negative. If the layer_parameter is set to positive,
+ # then the we change the extrusion direction.
+ # then the we change the extrusion direction. And the offset direction should remain positive
+ # for either direction sense, so we change it.
offset_direction *= -1
if layer_params["direction_sense"] == "POSITIVE":
- final_local_direction *= -1
+ direction_ratios *= -1
- # Check if extrusion direction actually changed
- final_local_norm = final_local_direction.normalized()
- local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6
-
- # Update extrusion properties
- extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction)
+ extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
if extrusion.Position or perpendicular_offset != 0:
position = offset_direction * perpendicular_offset
tool.Model.add_extrusion_position(extrusion, position)
- # Adjust object rotation if extrusion direction changed
- if local_direction_changed:
- # Calculate what the NEW world direction would be with current object rotation
- expected_new_world_direction = obj_rotation @ final_local_norm
-
- # The rotation needed is from expected_new_world_direction to current_world_direction
- rotation_axis = expected_new_world_direction.cross(current_world_direction)
- if rotation_axis.length > 1e-6:
- rotation_axis.normalize()
- dot_product = expected_new_world_direction.dot(current_world_direction)
- angle = acos(min(max(dot_product, -1), 1))
-
- # Rotate around object's own origin
- # Decompose the matrix to get translation, rotation, scale
- translation, rotation, scale = obj.matrix_world.decompose()
-
- # Create rotation matrix and convert to quaternion
- rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis)
- rotation_quat = rotation_matrix.to_quaternion()
-
- # Apply rotation to existing rotation (quaternion multiplication)
- new_rotation = rotation_quat @ rotation
-
- # Reconstruct matrix_world with same translation, new rotation, same scale
- obj.matrix_world = (
- Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4)
- )
- bpy.context.view_layer.update()
-
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -602,6 +522,12 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
representation=representation,
)
+ # Object rotation
+ current_z_rot = obj.rotation_euler.z
+ rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X")
+ obj.rotation_euler = rot_mat.to_euler()
+ obj.rotation_euler.z = current_z_rot
+
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
return {"FINISHED"}
@@ -1096,7 +1022,6 @@ class DumbWallGenerator:
obj=obj,
representation=representation,
)
-
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric")
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"})
material = ifcopenshell.util.element.get_material(element)
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index 8fad709de3..b6f63f13ca 100644
--- a/src/bonsai/bonsai/bim/module/project/operator.py
+++ b/src/bonsai/bonsai/bim/module/project/operator.py
@@ -2735,17 +2735,27 @@ class IFCFileHandlerOperator(bpy.types.Operator):
# Keeping code in .invoke() as we'll probably add some
# popup windows later.
+ def clean_up_path(path: str) -> str:
+ # In Blender 4.5.6 there was a bug producing unncesseary double slash prefix
+ # breaking the paths. Issue is not present in 5.0+ and presumably will be solved in 4.5.7 too.
+ # https://projects.blender.org/blender/blender/issues/153822
+ if bpy.app.version == (4, 5, 6):
+ blender_prefix = "//"
+ if path.startswith(blender_prefix):
+ return path.removeprefix(blender_prefix)
+ return path
+
# `files` contain only .ifc files.
filepath = Path(self.directory)
# If user is just drag'n'dropping a single file -> load it as a new project,
# if they're holding ALT -> link the file/files to the current project.
if event.alt:
# Passing self.files directly results in TypeError.
- serialized_files = [{"name": f.name} for f in self.files]
+ serialized_files = [{"name": clean_up_path(f.name)} for f in self.files]
return bpy.ops.bim.link_ifc(directory=self.directory, files=serialized_files)
else:
if len(self.files) == 1:
- return bpy.ops.bim.load_project(filepath=(filepath / self.files[0].name).as_posix())
+ return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix())
else:
self.report(
{"INFO"},
diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py
index ea5c9c843a..08859af1f9 100644
--- a/src/bonsai/bonsai/bim/module/project/prop.py
+++ b/src/bonsai/bonsai/bim/module/project/prop.py
@@ -370,7 +370,7 @@ class BIMProjectProperties(PropertyGroup):
load_indexed_maps: BoolProperty(
name="Load Indexed Maps",
description="Load indexed maps (UV and color maps)",
- default=True,
+ default=False, # Very slow and hackishly implemented
)
links: CollectionProperty(name="Links", type=Link)
active_link_index: IntProperty(name="Active Link Index")
diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py
index 66412e7d48..7adf7b5fb9 100644
--- a/src/bonsai/bonsai/bim/module/root/data.py
+++ b/src/bonsai/bonsai/bim/module/root/data.py
@@ -134,23 +134,55 @@ class IfcClassData:
return [("FACE", "Face", "A planar face surface")]
templates = [
("EMPTY", "No Geometry", "Start with an empty object"),
- None,
- (
- "OBJ",
- "Tessellation From Object",
- "Use an object as a template to create a new tessellation",
- ),
- (
- "MESH",
- "Custom Tessellation",
- "Create a basic tessellated or faceted cube",
- ),
- (
- "EXTRUSION",
- "Custom Extruded Solid",
- "An extrusion from an arbitrary profile",
- ),
]
+
+ if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
+ templates.extend([None, ("WINDOW", "Window", "Parametric window")])
+ elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
+ templates.extend([None, ("DOOR", "Door", "Parametric door")])
+ elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"):
+ templates.extend([None, ("STAIR", "Stair", "Parametric stair")])
+ elif ifc_class in ("IfcRailingType", "IfcRailing"):
+ templates.extend([None, ("RAILING", "Railing", "Parametric railing")])
+ elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"):
+ templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")])
+ elif ifc_class and "Segment" in ifc_class:
+ templates.extend(
+ (
+ None,
+ (
+ "FLOW_SEGMENT_RECTANGULAR",
+ "Rectangular Distribution Segment",
+ "Works similarly to Profile, has distribution ports",
+ ),
+ (
+ "FLOW_SEGMENT_RECTANGULAR_HOLLOW",
+ "Rectangular Hollow Distribution Segment",
+ "Works similarly to Profile, has distribution ports",
+ ),
+ (
+ "FLOW_SEGMENT_CIRCULAR",
+ "Circular Distribution Segment",
+ "Works similarly to Profile, has distribution ports",
+ ),
+ (
+ "FLOW_SEGMENT_CIRCULAR_HOLLOW",
+ "Circular Hollow Distribution Segment",
+ "Works similarly to Profile, has distribution ports",
+ ),
+ )
+ )
+ if ifc_class and "IfcCableCarrierSegment" in ifc_class:
+ templates.extend(
+ (
+ (
+ "FLOW_SEGMENT_U_SHAPE",
+ "U-Shape Distribution Segment",
+ "Uses IfcUShapeProfileDef, has distribution ports",
+ ),
+ )
+ )
+
if ifc_class.endswith("Type") or ifc_class.endswith("Style"):
templates.extend(
[
@@ -172,38 +204,26 @@ class IfcClassData:
),
]
)
- if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
- templates.extend([None, ("WINDOW", "Window", "Parametric window")])
- elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
- templates.extend([None, ("DOOR", "Door", "Parametric door")])
- elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"):
- templates.extend([None, ("STAIR", "Stair", "Parametric stair")])
- elif ifc_class in ("IfcRailingType", "IfcRailing"):
- templates.extend([None, ("RAILING", "Railing", "Parametric railing")])
- elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"):
- templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")])
- elif ifc_class and "Segment" in ifc_class:
- templates.extend(
+ templates.extend(
+ [
+ None,
(
- None,
- (
- "FLOW_SEGMENT_RECTANGULAR",
- "Rectangular Distribution Segment",
- "Works similarly to Profile, has distribution ports",
- ),
- (
- "FLOW_SEGMENT_CIRCULAR",
- "Circular Distribution Segment",
- "Works similarly to Profile, has distribution ports",
- ),
- (
- "FLOW_SEGMENT_CIRCULAR_HOLLOW",
- "Circular Hollow Distribution Segment",
- "Works similarly to Profile, has distribution ports",
- ),
- )
- )
-
+ "OBJ",
+ "Tessellation From Object",
+ "Use an object as a template to create a new tessellation",
+ ),
+ (
+ "MESH",
+ "Custom Tessellation",
+ "Create a basic tessellated or faceted cube",
+ ),
+ (
+ "EXTRUSION",
+ "Custom Extruded Solid",
+ "An extrusion from an arbitrary profile",
+ ),
+ ]
+ )
return templates
@classmethod
diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py
index a2ac73f200..ac766172bd 100644
--- a/src/bonsai/bonsai/bim/module/root/operator.py
+++ b/src/bonsai/bonsai/bim/module/root/operator.py
@@ -697,6 +697,26 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
XDim=default_x_dim / unit_scale,
YDim=default_y_dim / unit_scale,
)
+ elif representation_template == "FLOW_SEGMENT_RECTANGULAR_HOLLOW":
+ default_x_dim = 0.4
+ default_y_dim = 0.2
+ default_thickness = 0.005
+ default_inner_fillet_radius = 0.005
+ default_outer_fillet_radius = 0.005
+ profile_name = (
+ f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}x{default_thickness*1000}"
+ )
+ profile = tool.Ifc.get().create_entity(
+ "IfcRectangleHollowProfileDef",
+ ProfileName=profile_name,
+ ProfileType="AREA",
+ XDim=default_x_dim / unit_scale,
+ YDim=default_y_dim / unit_scale,
+ WallThickness=default_thickness / unit_scale,
+ InnerFilletRadius=default_inner_fillet_radius / unit_scale,
+ OuterFilletRadius=default_outer_fillet_radius / unit_scale,
+ )
+
elif representation_template == "FLOW_SEGMENT_CIRCULAR":
default_diameter = 0.1
profile_name = f"{props.ifc_class}-{default_diameter*1000}"
@@ -717,6 +737,21 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
Radius=(default_diameter / 2) / unit_scale,
WallThickness=default_thickness,
)
+ elif representation_template == "FLOW_SEGMENT_U_SHAPE":
+ default_depth = 0.4
+ default_flange_width = 0.2
+ default_web_thickness = 0.005
+ default_flange_thickness = 0.005
+ profile_name = f"{props.ifc_class}-{default_depth*1000}x{default_flange_width*1000}x{default_web_thickness*1000}x{default_flange_thickness*1000}"
+ profile = tool.Ifc.get().create_entity(
+ "IfcUShapeProfileDef",
+ ProfileName=profile_name,
+ ProfileType="AREA",
+ Depth=default_depth / unit_scale,
+ FlangeWidth=default_flange_width / unit_scale,
+ WebThickness=default_web_thickness / unit_scale,
+ FlangeThickness=default_flange_thickness / unit_scale,
+ )
rel = ifcopenshell.api.material.assign_material(
tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet"
diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py
index 47e87a3caa..83f401a122 100644
--- a/src/bonsai/bonsai/bim/module/search/operator.py
+++ b/src/bonsai/bonsai/bim/module/search/operator.py
@@ -45,8 +45,12 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.prop import StrProperty
+if TYPE_CHECKING:
+ from bonsai.bim.prop import BIMFacet
-def draw_text_editor_header(self, context):
+
+def draw_text_editor_header(self: bpy.types.TEXT_HT_header, context: bpy.types.Context) -> None:
+ assert isinstance(context.space_data, bpy.types.SpaceTextEditor)
if context.space_data.text and context.space_data.text.name.startswith("FilterQuery_"):
layout = self.layout
layout.separator()
@@ -160,6 +164,7 @@ class FilterValueSuggestions(Operator):
def draw(self, context):
layout = self.layout
+ assert layout
filter_groups = tool.Search.get_filter_groups(self.module)
ifc_filter = filter_groups[self.group_index].filters[self.filter_index]
@@ -202,8 +207,8 @@ class FilterValueSuggestions(Operator):
results_are_suggestions=True,
)
- def get_suggestions(self, ifc_file, ifc_filter):
- suggestions = set()
+ def get_suggestions(self, ifc_file: ifcopenshell.file, ifc_filter: "BIMFacet") -> set[str]:
+ suggestions: set[str] = set()
if ifc_filter.type == "entity":
suggestions = self.get_entity_suggestions(ifc_file)
@@ -236,8 +241,8 @@ class FilterValueSuggestions(Operator):
return suggestions
- def build_hierarchy_path(self, element):
- path = []
+ def build_hierarchy_path(self, element: ifcopenshell.entity_instance) -> list[str]:
+ path: list[str] = []
current = element
while current:
@@ -262,8 +267,8 @@ class FilterValueSuggestions(Operator):
return path
- def get_entity_suggestions(self, ifc_file):
- all_classes = set()
+ def get_entity_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ all_classes: set[str] = set()
schema = tool.Ifc.schema()
for element_id in IfcStore.id_map.keys():
@@ -273,11 +278,13 @@ class FilterValueSuggestions(Operator):
try:
entity = schema.declaration_by_name(class_name).as_entity()
+ assert entity
current = entity
chain_names = [class_name]
while current.supertype():
supertype = current.supertype()
+ assert supertype
chain_names.insert(0, supertype.name())
current = supertype
@@ -295,8 +302,8 @@ class FilterValueSuggestions(Operator):
return all_classes
- def get_type_suggestions(self, ifc_file):
- suggestions = set()
+ def get_type_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ suggestions: set[str] = set()
for element_type in ifc_file.by_type("IfcTypeObject"):
if element_type.Name:
hierarchy_path = self.build_hierarchy_path(element_type)
@@ -306,15 +313,15 @@ class FilterValueSuggestions(Operator):
suggestions.add(element_type.Name)
return suggestions
- def get_material_suggestions(self, ifc_file):
+ def get_material_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions = set()
for material in ifc_file.by_type("IfcMaterial"):
if material.Name:
suggestions.add(material.Name)
return suggestions
- def get_location_suggestions(self, ifc_file):
- suggestions = set()
+ def get_location_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ suggestions: set[str] = set()
for spatial in ifc_file.by_type("IfcSpatialStructureElement"):
if spatial.Name:
hierarchy_path = self.build_hierarchy_path(spatial)
@@ -324,8 +331,8 @@ class FilterValueSuggestions(Operator):
suggestions.add(spatial.Name)
return suggestions
- def get_group_suggestions(self, ifc_file):
- suggestions = set()
+ def get_group_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ suggestions: set[str] = set()
for group in ifc_file.by_type("IfcGroup"):
if group.Name:
hierarchy_path = self.build_hierarchy_path(group)
@@ -335,15 +342,15 @@ class FilterValueSuggestions(Operator):
suggestions.add(group.Name)
return suggestions
- def get_classification_suggestions(self, ifc_file):
- suggestions = set()
+ def get_classification_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ suggestions: set[str] = set()
for ref in ifc_file.by_type("IfcClassificationReference"):
if ref.Identification:
suggestions.add(ref.Identification)
return suggestions
- def get_parent_suggestions(self, ifc_file):
- suggestions = set()
+ def get_parent_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ suggestions: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -371,9 +378,9 @@ class FilterValueSuggestions(Operator):
return suggestions
- def get_instance_suggestions(self, ifc_file):
- suggestions = set()
- element_data = []
+ def get_instance_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
+ suggestions: set[str] = set()
+ element_data: list[tuple[str, str]] = []
for element_id in IfcStore.id_map.keys():
try:
@@ -392,7 +399,7 @@ class FilterValueSuggestions(Operator):
except:
continue
- display_counts = {}
+ display_counts: dict[str, int] = {}
for display_str, global_id in element_data:
display_counts[display_str] = display_counts.get(display_str, 0) + 1
@@ -404,8 +411,8 @@ class FilterValueSuggestions(Operator):
return suggestions
- def get_property_sets(self, ifc_file):
- psets = set()
+ def get_property_sets(self, ifc_file: ifcopenshell.file) -> set[str]:
+ psets: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -418,8 +425,8 @@ class FilterValueSuggestions(Operator):
continue
return psets
- def get_property_names(self, ifc_file, pset_name):
- property_names = set()
+ def get_property_names(self, ifc_file: ifcopenshell.file, pset_name: str) -> set[str]:
+ property_names: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -435,8 +442,8 @@ class FilterValueSuggestions(Operator):
continue
return property_names
- def get_property_values(self, ifc_file, pset_name, property_name):
- property_values = set()
+ def get_property_values(self, ifc_file: ifcopenshell.file, pset_name: str, property_name: str) -> set[str]:
+ property_values: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -462,8 +469,8 @@ class FilterValueSuggestions(Operator):
continue
return property_values
- def get_attribute_names(self, ifc_file):
- attribute_names = set()
+ def get_attribute_names(self, ifc_file: ifcopenshell.file) -> set[str]:
+ attribute_names: set[str] = set()
schema = tool.Ifc.schema()
ifc_classes = set()
@@ -477,6 +484,7 @@ class FilterValueSuggestions(Operator):
for ifc_class in ifc_classes:
try:
entity = schema.declaration_by_name(ifc_class).as_entity()
+ assert entity
attributes = entity.all_attributes()
for attr in attributes:
attribute_names.add(attr.name())
@@ -485,8 +493,8 @@ class FilterValueSuggestions(Operator):
return attribute_names
- def get_attribute_values(self, ifc_file, attribute_name):
- attribute_values = set()
+ def get_attribute_values(self, ifc_file: ifcopenshell.file, attribute_name: str) -> set[str]:
+ attribute_values: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
@@ -780,11 +788,10 @@ class Search(Operator):
)
objs = [obj for e in results if isinstance(obj := tool.Ifc.get_object(e), bpy.types.Object)]
- for obj in objs:
- tool.Blender.set_object_selection(obj)
- if objs:
- tool.Blender.set_active_object(objs[0])
- self.report({"INFO"}, f"{len(results)} Results.")
+ active_object = next(iter(objs), None)
+ selection = tool.Blender.validate_object_selection(context, active_object, objs)
+ tool.Blender.set_objects_selection(*selection, clear_previous_selection=False)
+ self.report({"INFO"}, f"{len(results)} Results, {len(selection.selected_objects)} Objects Selected")
return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py
index 18fc35c1c9..23c0c28335 100644
--- a/src/bonsai/bonsai/bim/module/spatial/operator.py
+++ b/src/bonsai/bonsai/bim/module/spatial/operator.py
@@ -153,16 +153,10 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator):
"Assign the selected objects to the container selected in Spatial Manager.\n\n"
"All elements-parts of an aggregate will be skipped.\n"
"To assign a container, they should be unassigned from an aggregate first.\n\n"
- "This will also move objects to the container collection in the outliner.\n"
- "ALT + Click to ensure objects are only linked in the container collection"
+ "This will also move objects to the container collection in the outliner."
)
bl_options = {"REGISTER", "UNDO"}
container: bpy.props.IntProperty(options={"SKIP_SAVE"})
- remove_from_other_containers: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
-
- def invoke(self, context, event):
- self.remove_from_other_containers = event.alt
- return self.execute(context)
def _execute(self, context):
if self.container:
@@ -177,31 +171,9 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator):
else:
return
- objs: list[bpy.types.Object] = []
- # In IFC element can be either contained of aggregated,
- # tehrefore we skip aggregated elements here to prevent confusion.
- # Can't handle it in `poll` since user might just select bunch of elements
- # and try to assign a container to them
- # and excluding aggregates because of the `poll` failing might get awkward.
- skipped_aggregates = 0
- for obj in tool.Blender.get_selected_objects():
- if not (element := tool.Ifc.get_entity(obj)):
- continue
- if ifcopenshell.util.element.get_aggregate(element):
- skipped_aggregates += 1
- continue
- objs.append(obj)
-
- for element_obj in objs:
- if self.remove_from_other_containers:
- for col in element_obj.users_collection[:]:
- col.objects.unlink(element_obj)
- core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj)
-
- aggregates_msg = ""
- if skipped_aggregates:
- aggregates_msg = f" {skipped_aggregates} aggregated elements skipped."
- self.report({"INFO"}, f"{len(objs)} elements assigned.{aggregates_msg}")
+ core.assign_container(
+ tool.Ifc, tool.Collector, tool.Spatial, container=container, objs=tool.Blender.get_selected_objects()
+ )
class EnableEditingContainer(bpy.types.Operator):
diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py
index 18ddc6080d..3d3d2e74f4 100644
--- a/src/bonsai/bonsai/bim/module/spatial/prop.py
+++ b/src/bonsai/bonsai/bim/module/spatial/prop.py
@@ -99,8 +99,7 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None:
tool.Spatial.edit_container_name(element, self.name)
if obj := tool.Ifc.get_object(element):
tool.Root.set_object_name(obj, element)
- if collection := tool.Blender.get_object_bim_props(obj).collection:
- collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}"
+ tool.Collector.assign(obj)
bonsai.bim.handler.refresh_ui_data()
@@ -175,8 +174,8 @@ def poll_container_obj(self: "BIMObjectSpatialProperties", container_obj: bpy.ty
obj = self.id_data
if (
(container := tool.Ifc.get_entity(container_obj))
- and (tool.Ifc.get_entity(obj))
- and tool.Spatial.can_contain(container, obj)
+ and (element := tool.Ifc.get_entity(obj))
+ and tool.Spatial.can_contain(container, element)
):
return True
return False
diff --git a/src/bonsai/bonsai/bim/module/system/__init__.py b/src/bonsai/bonsai/bim/module/system/__init__.py
index d08046ee8f..36901100f6 100644
--- a/src/bonsai/bonsai/bim/module/system/__init__.py
+++ b/src/bonsai/bonsai/bim/module/system/__init__.py
@@ -37,6 +37,7 @@ classes = (
operator.EditZone,
operator.EnableEditingSystem,
operator.EnableEditingZone,
+ operator.EstablishPathDirection,
operator.HidePorts,
operator.LoadSystems,
operator.LoadZones,
diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py
index 8dd6724de3..089bd9f24b 100644
--- a/src/bonsai/bonsai/bim/module/system/operator.py
+++ b/src/bonsai/bonsai/bim/module/system/operator.py
@@ -317,17 +317,19 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Connect MEP Elements"
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not len(context.selected_objects) == 2:
- cls.poll_message_set("Need to select 2 objects.")
- return False
- return True
+ obj1_name: bpy.props.StringProperty(name="Object 1")
+ obj2_name: bpy.props.StringProperty(name="Object 2")
def _execute(self, context):
- obj1 = context.active_object
- obj2 = next(o for o in context.selected_objects if o != obj1)
+ if self.obj1_name and self.obj2_name:
+ obj1 = bpy.data.objects.get(self.obj1_name)
+ obj2 = bpy.data.objects.get(self.obj2_name)
+ else:
+ if not context.selected_objects or len(context.selected_objects) != 2:
+ self.report({"ERROR"}, "Need to select 2 objects.")
+ return {"CANCELLED"}
+ obj1 = context.active_object
+ obj2 = next(o for o in context.selected_objects if o != obj1)
tool.Model.sync_object_ifc_position(obj1)
tool.Model.sync_object_ifc_position(obj2)
@@ -477,6 +479,55 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
+class EstablishPathDirection(bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.establish_path_direction"
+ bl_label = "Establish Path Direction"
+ bl_description = "Propagates flow direction through connected flow segments with two ports"
+ bl_options = {"REGISTER", "UNDO"}
+ port_id: bpy.props.IntProperty()
+
+ def _execute(self, context):
+ connected_port = tool.Ifc.get().by_id(self.port_id)
+
+ if not connected_port or not connected_port.is_a("IfcDistributionPort"):
+ self.report({"ERROR"}, "Invalid port specified.")
+ return {"CANCELLED"}
+
+ direction_map = {
+ "SOURCE": "SINK",
+ "SINK": "SOURCE",
+ "SOURCEANDSINK": "SOURCEANDSINK",
+ "NOTDEFINED": "NOTDEFINED",
+ }
+ next_element = tool.System.get_port_relating_element(connected_port)
+ ports = tool.System.get_ports(next_element)
+ segments_processed = 0
+ while len(ports) == 2:
+ if ports[0].id() == connected_port.id():
+ other_port = ports[1]
+ else:
+ other_port = ports[0]
+
+ new_direction = direction_map.get(connected_port.FlowDirection, "NOTDEFINED")
+ other_port.FlowDirection = new_direction
+ segments_processed += 1
+
+ connected_port = tool.System.get_connected_port(other_port)
+ if not connected_port:
+ break
+ connected_port.FlowDirection = direction_map.get(other_port.FlowDirection, "NOTDEFINED")
+ next_element = tool.System.get_port_relating_element(connected_port)
+
+ if not next_element.is_a("IfcFlowSegment"):
+ print(f"DEBUG: next_element is not IfcFlowSegment, stopping")
+ break
+
+ ports = tool.System.get_ports(next_element)
+
+ self.report({"INFO"}, f"Established path direction through {segments_processed} flow segment(s).")
+ return {"FINISHED"}
+
+
class LoadZones(bpy.types.Operator):
bl_idname = "bim.load_zones"
bl_label = "Load Zones"
diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py
index d621adc02b..e8f8b7daeb 100644
--- a/src/bonsai/bonsai/bim/module/system/ui.py
+++ b/src/bonsai/bonsai/bim/module/system/ui.py
@@ -296,7 +296,7 @@ class BIM_PT_port(Panel):
element = tool.Ifc.get_entity(context.active_object)
row = layout.row(align=True)
- cols = [row.column(align=True) for i in range(9)]
+ cols = [row.column(align=True) for i in range(10)]
cols[3].scale_x = 1.0
cols[6].scale_x = 1.0
cols[8].scale_x = 1.33
@@ -333,12 +333,16 @@ class BIM_PT_port(Panel):
else:
cols[7].label(text="", icon="BLANK1")
cols[8].label(text="")
+ cols[9].operator("bim.establish_path_direction", text="", icon="CON_FOLLOWPATH").port_id = (
+ connected_port.id()
+ )
else:
cols[4].label(text="", icon="BLANK1")
cols[5].label(text="", icon="BLANK1")
cols[6].label(text="Port is disconnected")
cols[7].label(text="", icon="BLANK1")
cols[8].label(text="")
+ cols[9].label(text="", icon="BLANK1")
class BIM_PT_flow_controls(Panel):
diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py
index f90d2165fb..d224761609 100644
--- a/src/bonsai/bonsai/bim/ui.py
+++ b/src/bonsai/bonsai/bim/ui.py
@@ -721,13 +721,20 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
chain_filter_with_set_operations: BoolProperty(
name="NEW Filter mode: Enable chained filters with set operations",
- description="Enable chaining search filters with set operations: ADD (union: combine sets), SUBTRACT (difference: remove from set), FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values",
+ description=(
+ "Enable chaining search filters with set operations: "
+ "ADD (union: combine sets), SUBTRACT (difference: remove from set), "
+ "FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values"
+ ),
default=False,
)
save_metadata_blend_file: BoolProperty(
name="Save non ifc data to metadata blend File",
- description="Save session data (window layout, settings) to a metadata blend file alongside the IFC file. This file is automatically loaded when opening the project.",
+ description=(
+ "Save session data (window layout, settings) to a metadata blend file alongside the IFC file. "
+ "This file is automatically loaded when opening the project."
+ ),
default=False,
)
metadata_blend_file_suffix: StringProperty(
diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py
index b56b51751f..42b0f77fbb 100644
--- a/src/bonsai/bonsai/core/drawing.py
+++ b/src/bonsai/bonsai/core/drawing.py
@@ -39,12 +39,44 @@ def disable_editing_text(drawing: type[tool.Drawing], obj: bpy.types.Object) ->
def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
- drawing.synchronise_ifc_and_text_attributes(obj)
- drawing.update_text_size_pset(obj)
- drawing.update_text_annotation_properties(obj)
+ literal_attributes = drawing.export_text_literal_attributes(obj)
+ drawing.edit_text_font_size(obj, drawing.export_font_size(obj))
+ drawing.edit_text_wrap_length(obj, drawing.export_wrap_length(obj))
+ drawing.edit_text_symbol(obj, drawing.export_symbol(obj))
+ drawing.edit_text_literals(obj, literal_attributes)
drawing.disable_editing_text(obj)
+def copy_text_to_selection(
+ drawing: type[tool.Drawing],
+ attribute: Literal["FONT_SIZE", "ALIGNMENT", "WRAP_LENGTH", "SYMBOL", "LITERALS"],
+ attribute_obj: bpy.types.Object,
+ apply_objs: list[bpy.types.Object],
+) -> None:
+ if attribute == "FONT_SIZE":
+ data = drawing.export_font_size(attribute_obj)
+ elif attribute == "ALIGNMENT":
+ data = drawing.export_alignment(attribute_obj)
+ elif attribute == "WRAP_LENGTH":
+ data = drawing.export_wrap_length(attribute_obj)
+ elif attribute == "SYMBOL":
+ data = drawing.export_symbol(attribute_obj)
+ elif attribute == "LITERALS":
+ data = drawing.export_text_literal_attributes(attribute_obj)
+ for obj in apply_objs:
+ if attribute == "FONT_SIZE":
+ drawing.edit_text_font_size(obj, data)
+ elif attribute == "ALIGNMENT":
+ drawing.edit_text_alignment(obj, data)
+ elif attribute == "WRAP_LENGTH":
+ drawing.edit_text_wrap_length(obj, data)
+ elif attribute == "SYMBOL":
+ drawing.edit_text_symbol(obj, data)
+ elif attribute == "LITERALS":
+ drawing.edit_text_literals(obj, data)
+ drawing.disable_editing_text(obj)
+
+
def enable_editing_assigned_product(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
drawing.enable_editing_assigned_product(obj)
drawing.import_assigned_product(obj)
diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py
index 4207d033d4..c464f84766 100644
--- a/src/bonsai/bonsai/core/spatial.py
+++ b/src/bonsai/bonsai/core/spatial.py
@@ -52,15 +52,22 @@ def assign_container(
collector: type[tool.Collector],
spatial: type[tool.Spatial],
container: ifcopenshell.entity_instance,
- element_obj: Optional[bpy.types.Object] = None,
+ objs: Optional[bpy.types.Object] = None,
) -> Union[ifcopenshell.entity_instance, None]:
- if not spatial.can_contain(container, element_obj):
- return
- assert element_obj # Type checker.
- rel = ifc.run("spatial.assign_container", products=[ifc.get_entity(element_obj)], relating_structure=container)
- spatial.disable_editing(element_obj)
- collector.assign(element_obj)
- return rel
+ root_elements = set()
+ all_elements = set()
+ for obj in objs:
+ if not (element := ifc.get_entity(obj)):
+ continue
+ root_element = spatial.get_root_element(element)
+ root_elements.add(root_element)
+ spatial.disable_editing(obj)
+ all_elements.add(root_element)
+ all_elements.update(spatial.get_decomposition(root_element))
+ if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
+ ifc.run("spatial.assign_container", products=products, relating_structure=container)
+ for element in all_elements:
+ collector.assign(ifc.get_object(element))
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
@@ -98,7 +105,7 @@ def copy_to_container(
copied_obj = spatial.duplicate_object_and_data(obj)
spatial.set_relative_object_matrix(copied_obj, to_container_obj, matrix)
result_objs.append(spatial.run_root_copy_class(obj=copied_obj))
- spatial.run_spatial_assign_container(container=to_container, element_obj=copied_obj)
+ spatial.run_spatial_assign_container(container=to_container, objs=[copied_obj])
spatial.disable_editing(obj)
return result_objs
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index 9d78297c4e..a0b6290b12 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -334,6 +334,11 @@ class Drawing:
def disable_editing_sheets(cls): pass
def disable_editing_text(cls, obj): pass
def does_file_exist(cls, uri): pass
+ def edit_text_alignment(cls, obj, alignment): pass
+ def edit_text_font_size(cls, obj, size): pass
+ def edit_text_literals(cls, obj, literals): pass
+ def edit_text_symbol(cls, obj, symbol): pass
+ def edit_text_wrap_length(cls, obj, wrap_length): pass
def enable_editing(cls, obj): pass
def enable_editing_assigned_product(cls, obj): pass
def enable_editing_drawings(cls): pass
@@ -402,10 +407,7 @@ class Drawing:
def setup_shading_styles_path(cls, resource_path): pass
def show_decorations(cls): pass
def sync_object_placement(cls, obj): pass
- def synchronise_ifc_and_text_attributes(cls, obj): pass
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
- def update_text_annotation_properties(cls, obj): pass
- def update_text_size_pset(cls, obj): pass
@interface
@@ -951,15 +953,17 @@ class Spatial:
def get_active_container(cls): pass
def get_container(cls, element): pass
def get_decomposed_elements(cls, container, recursive): pass
+ def get_decomposition(cls, element): pass
def get_object_matrix(cls, obj): pass
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
+ def get_root_element(cls, element): pass
def get_selected_product_types(cls): pass
def get_selected_products(cls): pass
def import_spatial_decomposition(cls): pass
def import_spatial_element(cls, element, level_index): pass
def load_contained_elements(cls): pass
def run_root_copy_class(cls, obj): pass
- def run_spatial_assign_container(cls, container, element_obj): pass
+ def run_spatial_assign_container(cls, container, objs): pass
def run_spatial_import_spatial_decomposition(cls): pass
def select_object(cls, obj): pass
def select_products(cls, products, unhide=False): pass
@@ -1123,15 +1127,15 @@ class Unit:
def disable_editing_units(cls): pass
def enable_editing_units(cls): pass
def export_unit_attributes(cls): pass
+ def get_currency_name(cls): pass
+ def get_project_currency_unit(cls): pass
def get_scene_unit_name(cls, unit_type): pass
def get_scene_unit_si_prefix(cls, name): pass
def import_unit_attributes(cls, unit): pass
def import_units(cls): pass
- def is_scene_unit_metric(cls): pass
+ def is_si_unit(cls, name): pass
def is_unit_class(cls, unit, ifc_class): pass
def set_active_unit(cls, unit): pass
- def get_project_currency_unit(cls): pass
- def get_currency_name(cls): pass
@interface
class Voider:
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index d4491e104a..e499f35106 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/blender.py
@@ -36,6 +36,7 @@ from typing import (
TYPE_CHECKING,
Any,
Literal,
+ NamedTuple,
Optional,
TypeVar,
Union,
@@ -49,7 +50,7 @@ import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
from ifcopenshell import entity_instance
-from mathutils import Vector
+from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
@@ -718,13 +719,18 @@ class Blender(bonsai.core.tool.Blender):
if active_object:
active_object.select_set(True)
+ class ObjectsSelectionArgs(NamedTuple):
+ context: bpy.types.Context
+ active_object: bpy.types.Object | None
+ selected_objects: list[bpy.types.Object]
+
@classmethod
def validate_object_selection(
cls,
context: bpy.types.Context,
active_object: Union[bpy.types.Object, None] = None,
selected_objects: Sequence[bpy.types.Object] = (),
- ) -> tuple[bpy.types.Context, Union[bpy.types.Object, None], list[bpy.types.Object]]:
+ ) -> ObjectsSelectionArgs:
"""Validate object selection and return only valid objects.
Can be used before ``set_objects_selection`` to avoid errors
@@ -734,12 +740,15 @@ class Blender(bonsai.core.tool.Blender):
assert context.view_layer
view_layer_objects = set(context.view_layer.objects)
- new_selected_objects = [o for o in selected_objects if cls.is_valid_data_block(o) and o in view_layer_objects]
+ def is_selectable(obj: bpy.types.Object) -> bool:
+ return cls.is_valid_data_block(obj) and obj in view_layer_objects
- if active_object and not cls.is_valid_data_block(active_object):
+ new_selected_objects = [o for o in selected_objects if is_selectable(o)]
+
+ if active_object and not is_selectable(active_object):
active_object = None
- return context, active_object, new_selected_objects
+ return cls.ObjectsSelectionArgs(context, active_object, new_selected_objects)
@classmethod
def clear_objects_selection(cls) -> None:
@@ -2127,3 +2136,32 @@ class Blender(bonsai.core.tool.Blender):
if cls.BLENDER_5:
return "BLENDER_EEVEE"
return "BLENDER_EEVEE_NEXT"
+
+ @classmethod
+ def np_frombuffer_legacy(cls, bytedata: bytes, n: int) -> npt.NDArray[np.float32]:
+ """
+ Read ``n`` float values from ``bytedata``, regardless if they are stored as ``float32`` or ``float64``.
+ Needed to support .blend files saved in Blender <5.0.0.
+ Also allows to work with .blend files from 5.0.0+ in older Blender versions.
+
+ In ``bpy.app.version >= 5.0.0`` ``mathutils`` transitioned to use ``float32`` buffer type,
+ while in previous version they were using ``float64``.
+ In some cases we are storing raw bytes (e.g. object transforms cheksums), so old .blend files
+ might still have ``float64`` data stored.
+
+ See https://projects.blender.org/blender/blender/issues/149283
+ """
+ if len(bytedata) == (n * 2):
+ return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
+ return np.frombuffer(bytedata, dtype=np.float32)
+
+ @classmethod
+ def np_array_legacy(cls, mathutils_type: Union[Vector, Matrix]) -> npt.NDArray[np.float32]:
+ """
+ Converts ``mathutils`` types to ``np.float32`` arrays, regardless of Blender version.
+
+ See ``np_frombuffer_legacy`` for more details.
+ """
+ if cls.BLENDER_5:
+ return np.array(mathutils_type)
+ return np.array(mathutils_type, dtype=np.float32)
diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py
index 01c13d6cec..74a55d7dc1 100644
--- a/src/bonsai/bonsai/tool/collector.py
+++ b/src/bonsai/bonsai/tool/collector.py
@@ -46,6 +46,8 @@ class Collector(bonsai.core.tool.Collector):
# Note that tool.Geometry.is_locked is only checked within the if
# statements for efficiency as it is a slow check.
tool.Geometry.lock_scale(obj)
+ if element.is_a("IfcSlab"):
+ tool.Geometry.lock_rotation(obj, x=True)
if element.is_a("IfcGridAxis"):
if tool.Geometry.is_locked(element):
diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py
index 771b15f43e..663b16040e 100644
--- a/src/bonsai/bonsai/tool/cost.py
+++ b/src/bonsai/bonsai/tool/cost.py
@@ -23,6 +23,7 @@ from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never
+import aud
import bpy
import ifcopenshell.api
import ifcopenshell.api.cost
@@ -143,25 +144,18 @@ class Cost(bonsai.core.tool.Cost):
@classmethod
def play_sound(cls) -> None:
- if tool.Blender.get_addon_preferences().should_play_chaching_sound:
+ # Save ears for those running the tests in background mode.
+ if tool.Blender.get_addon_preferences().should_play_chaching_sound and not bpy.app.background:
cls.play_chaching_sound() # lol
@classmethod
def play_chaching_sound(cls) -> None:
# TODO: make pitch higher as costs rise
- try:
- import aud
-
- device = aud.Device()
- # chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
- sound = aud.Sound(tool.Blender.get_data_dir_path(filename="chaching.mp3").__str__())
- handle = device.play(sound)
- sound_buffered = aud.Sound.buffer(sound)
- handle_buffered = device.play(sound_buffered)
- handle.stop()
- handle_buffered.stop()
- except:
- pass # ah well
+ 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)
+ device.play(sound)
@classmethod
def load_cost_schedule_tree(cls) -> None:
diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py
index 51d06c1c18..3b98b92611 100644
--- a/src/bonsai/bonsai/tool/drawing.py
+++ b/src/bonsai/bonsai/tool/drawing.py
@@ -608,6 +608,25 @@ class Drawing(bonsai.core.tool.Drawing):
literals.append(literal_data)
return literals
+ @classmethod
+ def export_font_size(cls, obj: bpy.types.Object) -> str:
+ return float(cls.get_text_props(obj).font_size)
+
+ @classmethod
+ def export_alignment(cls, obj: bpy.types.Object) -> str:
+ props = cls.get_text_props(obj)
+ if (alignment := props.align_vertical + "-" + props.align_horizontal) == "middle-middle":
+ return "center"
+ return alignment
+
+ @classmethod
+ def export_wrap_length(cls, obj: bpy.types.Object) -> str:
+ return cls.get_text_props(obj).newline_at
+
+ @classmethod
+ def export_symbol(cls, obj: bpy.types.Object) -> str:
+ return cls.get_text_props(obj).get_symbol()
+
@classmethod
def create_annotation_context(
cls, target_view: str, object_type: Optional[str] = None
@@ -835,43 +854,12 @@ class Drawing(bonsai.core.tool.Drawing):
return props.is_editing_sheets
@classmethod
- def synchronise_ifc_and_text_attributes(cls, obj: bpy.types.Object) -> None:
+ def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
-
- old_literals = cls.get_text_literal(obj, return_list=True)
- assert isinstance(old_literals, list)
- literals_attributes = cls.export_text_literal_attributes(obj)
- props = cls.get_text_props(obj)
- defined_ifc_ids = [l.ifc_definition_id for l in props.literals]
- ifc_file = tool.Ifc.get()
-
- added_literals: list[ifcopenshell.entity_instance] = []
- new_literals: list[ifcopenshell.entity_instance] = []
- for ifc_definition_id, attributes in zip(defined_ifc_ids, literals_attributes):
- # making sure all literals from text edit exist in ifc
- if ifc_definition_id == 0:
- literal = cls.add_literal(**attributes)
- added_literals.append(literal)
- else:
- literal = ifc_file.by_id(ifc_definition_id)
- ifcopenshell.api.drawing.edit_text_literal(
- ifc_file,
- text_literal=literal,
- attributes=attributes,
- )
- new_literals.append(literal)
-
- removed_literals = set(old_literals) - set(new_literals)
-
- # Add new literals and keep the order as defined in text props.
- items = [i for i in rep.Items if i not in removed_literals] + added_literals
- items.sort(key=lambda x: new_literals.index(x) if x in new_literals else -1)
- rep.Items = items
-
- # Remove from ifc the literals that were removed during the edit.
- for literal in removed_literals:
- ifcopenshell.util.element.remove_deep2(ifc_file, literal)
+ for literal in cls.get_text_literal(obj, return_list=True):
+ ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
+ rep.Items = [cls.add_literal(**a) for a in literal_attributes]
@classmethod
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
@@ -1212,8 +1200,6 @@ class Drawing(bonsai.core.tool.Drawing):
props.font_size = str(text_data["FontSize"])
props.newline_at = text_data["Newline_At"]
props.set_symbol(text_data["Symbol"])
- props.reverse_list = text_data["Reverse_List"]
- props.list_separator = text_data["List_Separator"]
@classmethod
def import_assigned_product(cls, obj: bpy.types.Object) -> None:
@@ -1310,7 +1296,7 @@ class Drawing(bonsai.core.tool.Drawing):
props.should_draw_decorations = True
@classmethod
- def update_text_size_pset(cls, obj: bpy.types.Object) -> None:
+ def edit_text_font_size(cls, obj: bpy.types.Object, font_size: float) -> None:
"""updates pset `EPset_Annotation.Classes` value
based on current font size from `obj.BIMTextProperties.font_size`
"""
@@ -1320,8 +1306,9 @@ class Drawing(bonsai.core.tool.Drawing):
element = tool.Ifc.get_entity(obj)
assert element
# updating text font size in EPset_Annotation.Classes
- font_size = float(props.font_size)
+ print("we got", font_size, repr(font_size))
font_size_str = next((key for key in FONT_SIZES if FONT_SIZES[key] == font_size), None)
+ print("so", font_size_str)
classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes")
assert isinstance(classes, Union[str, None])
classes_split = classes.split() if classes else []
@@ -1341,34 +1328,32 @@ class Drawing(bonsai.core.tool.Drawing):
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
- ifcopenshell.api.pset.edit_pset(
- ifc_file,
- pset=pset,
- properties={"Classes": classes},
- )
+ ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Classes": classes})
@classmethod
- def update_text_annotation_properties(cls, obj: bpy.types.Object) -> None:
- """Update all EPset_Annotation properties from the text props"""
- props = cls.get_text_props(obj)
+ def edit_text_wrap_length(cls, obj: bpy.types.Object, wrap_length: int) -> None:
element = tool.Ifc.get_entity(obj)
- assert element
-
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
+ ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Newline_At": wrap_length})
- ifcopenshell.api.pset.edit_pset(
- ifc_file,
- pset=pset,
- properties={
- "Newline_At": int(props.newline_at),
- "Symbol": props.get_symbol(),
- "Reverse_List": props.reverse_list,
- "List_Separator": props.list_separator or "",
- },
- )
+ @classmethod
+ def edit_text_symbol(cls, obj: bpy.types.Object, symbol: str) -> None:
+ element = tool.Ifc.get_entity(obj)
+ ifc_file = tool.Ifc.get()
+ pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
+ if not pset:
+ pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
+ ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Symbol": symbol})
+
+ @classmethod
+ def edit_text_alignment(cls, obj: bpy.types.Object, alignment: str) -> None:
+ ifc_literals = cls.get_text_literal(obj, return_list=True)
+ for ifc_literal in ifc_literals or []:
+ if ifc_literal.is_a("IfcTextLiteralWithExtent"):
+ ifc_literal.BoxAlignment = alignment
# TODO below this point is highly experimental prototype code with no tests
@@ -2112,13 +2097,9 @@ class Drawing(bonsai.core.tool.Drawing):
cls,
text: str,
product: Optional[ifcopenshell.entity_instance] = None,
- reverse_list: bool = False,
- list_separator: str = ", ",
) -> str:
if not product:
return text
- if list_separator:
- list_separator = list_separator.encode().decode("unicode_escape")
for command in re.findall("``.*?``", text):
original_command = command
@@ -2133,10 +2114,7 @@ class Drawing(bonsai.core.tool.Drawing):
for variable in re.findall("{{.*?}}", text):
value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2])
if isinstance(value, (list, tuple)):
- if reverse_list:
- value = list_separator.join(str(v) for v in reversed(value))
- else:
- value = list_separator.join(str(v) for v in value)
+ value = ", ".join(str(v) for v in value)
text = text.replace(variable, str(value))
return text
diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py
index 5a6429adfe..13ec6695d2 100644
--- a/src/bonsai/bonsai/tool/geometry.py
+++ b/src/bonsai/bonsai/tool/geometry.py
@@ -1152,9 +1152,8 @@ class Geometry(bonsai.core.tool.Geometry):
def record_object_position(cls, obj: bpy.types.Object) -> None:
# These are recorded separately because they have different numerical tolerances
props = tool.Blender.get_object_bim_props(obj)
- # Explicit dtype for Blender <5.0 compatibility.
- props.location_checksum = repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes())
- props.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
+ props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
+ props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
@classmethod
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py
index 81c5202c19..8cbd2a112b 100644
--- a/src/bonsai/bonsai/tool/ifc.py
+++ b/src/bonsai/bonsai/tool/ifc.py
@@ -115,24 +115,17 @@ class Ifc(bonsai.core.tool.Ifc):
return True # Let's be conservative
# Handle both old float64 and new float32 checksums for version compatibility
- loc_checksum_bytes = eval(oprops.location_checksum)
- if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64)
- loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32)
- else: # New format: 3 * 4 bytes (float32)
- loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32)
-
- loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten()
+ loc_checksum_bytes: bytes = eval(oprops.location_checksum)
+ loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3)
+ loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation)
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
return True
# Handle both old float64 and new float32 checksums for version compatibility
- rot_checksum_bytes = eval(oprops.rotation_checksum)
- if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64)
- rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3)
- else: # New format: 9 * 4 bytes (float32)
- rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3)
-
- rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32)
+ rot_checksum_bytes: bytes = eval(oprops.rotation_checksum)
+ rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
+ rot_check = rot_check.reshape(3, 3)
+ 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))
if angle_rad > 0.0017453292519943296: # 0.1 degrees
diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py
index 6854b38432..db557542bc 100644
--- a/src/bonsai/bonsai/tool/ifcgit.py
+++ b/src/bonsai/bonsai/tool/ifcgit.py
@@ -64,10 +64,13 @@ class IfcGit:
@classmethod
def clone_repo(cls, remote_url: str, local_folder: str) -> git.Repo:
- IfcGitRepo.repo = git.Repo.clone_from(
+ repo = git.Repo.clone_from(
url=remote_url,
to_path=local_folder,
)
+ # Close the repo to release stale subprocess
+ repo.close()
+ IfcGitRepo.repo = git.Repo(local_folder)
cls.config_info_attributes(IfcGitRepo.repo)
return IfcGitRepo.repo
diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py
index 8fdce1da4c..d81fec5fd2 100644
--- a/src/bonsai/bonsai/tool/loader.py
+++ b/src/bonsai/bonsai/tool/loader.py
@@ -39,6 +39,7 @@ import numpy as np
import numpy.typing as npt
from ifcopenshell.util.shape_builder import np_to_4d
from mathutils import Matrix, Vector
+from mathutils.kdtree import KDTree
import bonsai.bim.import_ifc
import bonsai.core.tool
@@ -562,7 +563,18 @@ class Loader(bonsai.core.tool.Loader):
bm_verts = np.array([v.co for v in bm.verts])
coords_scaled = np.array(faceset.Coordinates.CoordList) * si_conversion
- coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled]
+ # See #2824. IfcIndexedColourMap is not natively handled by IfcOpenShell
+ # As a result, we map IFC coords to Blender coords (highly wasteful but...)
+ # coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled]
+ # Because this is O(N*M), here is a faster KDTree implementation.
+ kd = KDTree(len(bm_verts))
+ for i, v in enumerate(bm_verts):
+ kd.insert((float(v[0]), float(v[1]), float(v[2])), i)
+ kd.balance()
+ coordinates_remap = np.empty(len(coords_scaled), dtype=np.int32)
+ for j, co in enumerate(coords_scaled):
+ _co, index, _dist = kd.find((float(co[0]), float(co[1]), float(co[2])))
+ coordinates_remap[j] = index
# ifc indices start with 1
remap_verts_to_blender = lambda ifc_verts: [coordinates_remap[i - 1] for i in ifc_verts]
@@ -1024,7 +1036,7 @@ class Loader(bonsai.core.tool.Loader):
elif material.is_a("IfcMaterialLayerSetUsage"):
usage = material
layer_set = material.ForLayerSet
- offset = usage.OffsetFromReferenceLine
+ offset = usage.OffsetFromReferenceLine * cls.unit_scale
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
elif material.is_a("IfcMaterialLayerSet"):
usage = None
@@ -1033,168 +1045,61 @@ class Loader(bonsai.core.tool.Loader):
sense_factor = 1
else:
return mesh
-
if len(layer_set.MaterialLayers) == 1:
return mesh
-
- # Get mesh bounds
- if len(mesh.vertices) > 0:
- z_coords = [v.co.z for v in mesh.vertices]
- mesh_z_min = min(z_coords)
- mesh_z_max = max(z_coords)
-
bm = bmesh.new()
bm.from_mesh(mesh)
-
prev_co = None
- advance_direction = None
-
if not usage:
- sense_factor = 1
+ sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = cls.get_extrusion_vector(element).normalized()
co = Vector((0.0, 0.0, offset))
- advance_direction = no
elif usage.LayerSetDirection == "AXIS2":
- # Get local extrusion direction
- local_extrusion = Vector([0.0, 0.0, 1.0])
- if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
- for item in ifcopenshell.util.representation.resolve_representation(body).Items:
- while item.is_a("IfcBooleanResult"):
- item = item.FirstOperand
- if item.is_a("IfcExtrudedAreaSolid"):
- local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized()
- break
-
- # Thickness direction: perpendicular to extrusion and length
- thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized()
- if thickness_dir.y < 0:
- thickness_dir = -thickness_dir
-
- no = thickness_dir
-
- # Find start point by projecting vertices onto thickness direction
- if len(mesh.vertices) > 0:
- projections = [Vector(v.co).dot(no) for v in mesh.vertices]
- min_proj = min(projections)
- max_proj = max(projections)
-
- centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
- centroid_proj = centroid.dot(no)
-
- if sense_factor == 1:
- start_proj = min_proj
- else:
- start_proj = max_proj
-
- offset_dist = start_proj - centroid_proj
- co = centroid + no * offset_dist
-
- actual_mesh_height = max_proj - min_proj
- else:
- co = Vector((0.0, 0.0, 0.0))
-
- advance_direction = thickness_dir
+ 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":
- # AXIS3 layers go through slab thickness (local Z)
+ co = Vector((0.0, 0.0, offset))
+ no = cls.get_extrusion_vector(element).normalized()
no = Vector([0.0, 0.0, 1.0])
-
- # Find start point by projecting vertices onto Z direction
- if len(mesh.vertices) > 0:
- projections = [Vector(v.co).dot(no) for v in mesh.vertices]
- min_proj = min(projections)
- max_proj = max(projections)
-
- centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
- centroid_proj = centroid.dot(no)
-
- if sense_factor == 1:
- start_proj = min_proj
- else:
- start_proj = max_proj
-
- offset = start_proj - centroid_proj
- co = centroid + no * offset
-
- actual_mesh_height = max_proj - min_proj
- else:
- co = Vector((0.0, 0.0, 0.0))
-
- advance_direction = no
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
- advance_direction = no
-
- # Apply DirectionSense
- if usage and usage.LayerSetDirection == "AXIS2":
- if sense_factor == -1:
- advance_direction = -advance_direction
- test_normal = -no
- else:
- test_normal = no
- elif usage and usage.LayerSetDirection == "AXIS1":
- no = no * sense_factor
- advance_direction = advance_direction * sense_factor
- test_normal = no
- elif usage and usage.LayerSetDirection == "AXIS3":
- if sense_factor == -1:
- advance_direction = -advance_direction
- test_normal = -no
- else:
- test_normal = no
- else:
- test_normal = no
-
- # Cache material styles
+ no *= sense_factor
+ # Cache this
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
styles = {}
has_layer_styles = False
for i, material in enumerate(mesh.materials):
if style := tool.Ifc.get_entity(material):
styles[style] = i
-
- layer_list = list(enumerate(layer_set.MaterialLayers))
-
- # Calculate scale factor
- total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list)
-
- if "actual_mesh_height" not in locals():
- actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness
-
- thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0
-
last_i = len(layer_set.MaterialLayers) - 1
-
- for idx, (original_i, layer) in enumerate(layer_list):
- if idx != last_i:
+ for i, layer in enumerate(layer_set.MaterialLayers):
+ if i != last_i:
prev_co = co.copy()
- advance_vector = advance_direction * layer.LayerThickness * thickness_scale
- co += advance_vector
-
+ co += no * layer.LayerThickness * cls.unit_scale
bisect_geom = bmesh.ops.bisect_plane(
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no
)
bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"])
-
if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)):
continue
if (material_index := styles.get(style, None)) is None:
material_index = len(mesh.materials)
mesh.materials.append(tool.Ifc.get_object(style))
-
- if idx == last_i:
+ if i == last_i:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
- if (center - co).dot(test_normal) >= 0:
+ if (center - co).dot(no) >= 0:
face.material_index = material_index
has_layer_styles = True
else:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
- if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0:
+ if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0:
face.material_index = material_index
has_layer_styles = True
@@ -1207,35 +1112,13 @@ class Loader(bonsai.core.tool.Loader):
return mesh
@classmethod
- def get_extrusion_vector(cls, element):
- """Get the extrusion direction in WORLD coordinates (accounting for object rotation)"""
- if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
+ def get_extrusion_vector(cls, wall):
+ if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
- local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
-
- # Transform to world coordinates using object rotation
- obj = tool.Ifc.get_object(element)
- if obj:
- # Apply object rotation to get actual world direction
- world_direction = obj.matrix_world.to_3x3() @ local_direction
- return world_direction
-
- return local_direction
- return Vector([0.0, 0.0, 1.0])
-
- @classmethod
- def get_local_extrusion_vector(cls, element):
- """Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)"""
- if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
- for item in ifcopenshell.util.representation.resolve_representation(body).Items:
- while item.is_a("IfcBooleanResult"):
- item = item.FirstOperand
- if item.is_a("IfcExtrudedAreaSolid"):
- local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
- return local_direction
+ return Vector(item.ExtrudedDirection.DirectionRatios)
return Vector([0.0, 0.0, 1.0])
@classmethod
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index 08a0a0fd85..148f4a6b75 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.py
@@ -1051,6 +1051,7 @@ class Model(bonsai.core.tool.Model):
tool.Model.regenerate_array(obj, array_data)
+ array_pset = tool.Pset.get_element_pset(element, "BBIM_Array")
json_data = tool.Ifc.get().createIfcText(json.dumps(array_data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
@@ -1063,22 +1064,17 @@ class Model(bonsai.core.tool.Model):
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] = tuple()
) -> None:
"""`array_layers_to_apply` - list of array layer indices to apply"""
- tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent_obj))
+ parent_element = tool.Ifc.get_entity(parent_obj)
+
+ if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"):
+ ifcopenshell.api.pset.remove_pset(
+ tool.Ifc.get(), product=parent_element, pset=tool.Ifc.get().by_id(pset["id"])
+ )
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj_stack = [parent_obj]
for array_i, array in enumerate(data):
- # for `sync_children` we remove all previously generated children to regenerate them again
- # to assure they are in complete sync (psets, etc) with the array parent
- if array["sync_children"]:
- removed_children = set(array["children"])
- for removed_child in removed_children:
- element = tool.Ifc.get().by_guid(removed_child)
- if obj := tool.Ifc.get_object(element):
- tool.Geometry.delete_ifc_object(obj)
- array["children"].clear()
-
child_i = 0
existing_children = set(array["children"])
total_existing_children = len(array["children"])
@@ -1104,22 +1100,21 @@ class Model(bonsai.core.tool.Model):
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except:
- old_to_new, _ = tool.Geometry.duplicate_ifc_objects([obj])
- # TODO Is this correct to assume one child? I really
- # don't understand the linked aggregates and array
- # behaviour.
+ old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
child_obj = tool.Ifc.get_object(child_element)
# add child pset
- child_pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
- if child_pset:
- ifcopenshell.api.pset.edit_pset(
- tool.Ifc.get(),
- pset=child_pset,
- properties={"Data": None},
- should_purge=False,
+ if not (child_pset := tool.Pset.get_element_pset(child_element, "BBIM_Array")):
+ child_pset = ifcopenshell.api.pset.add_pset(
+ tool.Ifc.get(), product=child_element, name="BBIM_Array"
)
+ ifcopenshell.api.pset.edit_pset(
+ tool.Ifc.get(),
+ pset=child_pset,
+ properties={"Data": None, "Parent": parent_element.GlobalId},
+ should_purge=False,
+ )
# set child object position
new_matrix = obj.matrix_world.copy()
@@ -1155,6 +1150,12 @@ class Model(bonsai.core.tool.Model):
bpy.context.view_layer.update()
+ pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=parent_element, name="BBIM_Array")
+ json_data = tool.Ifc.get().createIfcText(json.dumps(data))
+ ifcopenshell.api.pset.edit_pset(
+ tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
+ )
+
@classmethod
def replace_object_ifc_representation(
cls,
diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py
index 70a92f4cff..d38d895c22 100644
--- a/src/bonsai/bonsai/tool/spatial.py
+++ b/src/bonsai/bonsai/tool/spatial.py
@@ -74,9 +74,25 @@ class Spatial(bonsai.core.tool.Spatial):
return bpy.context.scene.BIMGridProperties
@classmethod
- def can_contain(cls, container: ifcopenshell.entity_instance, element_obj: Union[bpy.types.Object, None]) -> bool:
- if not (element := tool.Ifc.get_entity(element_obj)):
- return False
+ def get_decomposition(cls, element: ifcopenshell.entity_instance) -> list(ifcopenshell.entity_instance):
+ return ifcopenshell.util.element.get_decomposition(element)
+
+ @classmethod
+ def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
+ while True:
+ if parent := (
+ ifcopenshell.util.element.get_aggregate(element)
+ or ifcopenshell.util.element.get_nest(element)
+ or ifcopenshell.util.element.get_filled_void(element)
+ or ifcopenshell.util.element.get_voided_element(element)
+ ):
+ element = parent
+ else:
+ break
+ return element
+
+ @classmethod
+ def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
if tool.Ifc.get_schema() == "IFC2X3":
if not container.is_a("IfcSpatialStructureElement"):
return False
@@ -147,10 +163,10 @@ class Spatial(bonsai.core.tool.Spatial):
@classmethod
def run_spatial_assign_container(
- cls, container: ifcopenshell.entity_instance, element_obj: bpy.types.Object
+ cls, container: ifcopenshell.entity_instance, objs: list[bpy.types.Object]
) -> Union[ifcopenshell.entity_instance, None]:
return bonsai.core.spatial.assign_container(
- tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj
+ tool.Ifc, tool.Collector, tool.Spatial, container=container, objs=objs
)
@classmethod
diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py
index 66b56612fe..1221f28be9 100644
--- a/src/bonsai/bonsai/tool/unit.py
+++ b/src/bonsai/bonsai/tool/unit.py
@@ -419,11 +419,6 @@ class Unit(bonsai.core.tool.Unit):
new.is_assigned = unit in assigned_units
new.ifc_class = unit.is_a()
- @classmethod
- def is_scene_unit_metric(cls) -> bool:
- assert bpy.context.scene
- return bpy.context.scene.unit_settings.system in ["METRIC", "NONE"]
-
@classmethod
def is_unit_class(cls, unit: ifcopenshell.entity_instance, ifc_class: str) -> bool:
return unit.is_a(ifc_class)
diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini
index 001313dd30..e628606201 100644
--- a/src/bonsai/pytest.ini
+++ b/src/bonsai/pytest.ini
@@ -1,6 +1,7 @@
[pytest]
markers =
aggregate
+ array
attribute
boolean
boundary
diff --git a/src/bonsai/scripts/dev_environment.py b/src/bonsai/scripts/dev_environment.py
index 4598d0bf49..e9a8d2a44f 100644
--- a/src/bonsai/scripts/dev_environment.py
+++ b/src/bonsai/scripts/dev_environment.py
@@ -78,7 +78,8 @@ BONSAI_PATH = find_bonsai_path()
# ---------------------------
# Never changed by user.
-PACKAGE_PATH = BLENDER_PATH / r"extensions/.local/lib/python3.11/site-packages"
+PYTHON_VERSION = "3.13" if BLENDER_VERSION == "5.1" else "3.11"
+PACKAGE_PATH = BLENDER_PATH / rf"extensions/.local/lib/python{PYTHON_VERSION}/site-packages"
def main() -> None:
@@ -193,7 +194,7 @@ def main() -> None:
print(f"Downloading {url} -> {filepath}")
urllib.request.urlretrieve(url, filepath)
- input("Dev environment is all set. 🎉🎉\nPress Enter to continue..." "")
+ input("Dev environment is all set!! \nPress Enter to continue...")
if __name__ == "__main__":
diff --git a/src/bonsai/test/bim/feature/array.feature b/src/bonsai/test/bim/feature/array.feature
new file mode 100644
index 0000000000..23fa4c0080
--- /dev/null
+++ b/src/bonsai/test/bim/feature/array.feature
@@ -0,0 +1,276 @@
+@array
+Feature: Array
+
+Scenario: Add array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ When the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I see "No Array Found"
+ And I click "ADD"
+ Then the object "IfcColumn/Column" exists
+ And I see "Column"
+ And I see "1 Items"
+ And I don't see "No Array Found"
+ And the object "IfcColumn/Column" is at "0,0,0"
+
+Scenario: Enable editing array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ When I click "GREASEPENCIL"
+ Then I see "Count"
+ And I see "Method"
+ And I don't see "1 Items"
+
+Scenario: Disable editing array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I click "CANCEL"
+ Then I see "1 Items"
+ And I don't see "Count"
+
+Scenario: Edit array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "2"
+ And I click "CHECKMARK"
+ Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column" is at "0,0,0"
+ And the object "IfcColumn/Column.001" is at "0,0,0"
+ And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
+
+Scenario: Edit array - offset method
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "3"
+ And I set the "Method" property to "Offset"
+ And I set the "X" property to "1"
+ And I click "CHECKMARK"
+ Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column" is at "0,0,0"
+ And the object "IfcColumn/Column.001" is at "1,0,0"
+ And the object "IfcColumn/Column.002" is at "2,0,0"
+ And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
+
+Scenario: Edit array - distribute method
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "3"
+ And I set the "Method" property to "Distribute"
+ And I set the "X" property to "2"
+ And I click "CHECKMARK"
+ Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column" is at "0,0,0"
+ And the object "IfcColumn/Column.001" is at "1,0,0"
+ And the object "IfcColumn/Column.002" is at "2,0,0"
+ And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
+
+Scenario: Edit array - local space
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is rotated by "0,0,90" deg
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "2"
+ And I set the "Method" property to "Offset"
+ And I set the "Use Local Space" property to "TRUE"
+ And I set the "X" property to "1"
+ And I click "CHECKMARK"
+ Then the object "IfcColumn/Column" is at "0,0,0"
+ And the object "IfcColumn/Column.001" is at "0,1,0"
+
+Scenario: Edit array - world space
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is rotated by "0,0,90" deg
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "2"
+ And I set the "Method" property to "Offset"
+ And I set the "Use Local Space" property to "FALSE"
+ And I set the "X" property to "1"
+ And I click "CHECKMARK"
+ Then the object "IfcColumn/Column" is at "0,0,0"
+ And the object "IfcColumn/Column.001" is at "1,0,0"
+
+Scenario: Edit array - decrease count
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "3"
+ And I click "CHECKMARK"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "2"
+ And I click "CHECKMARK"
+ Then the object "IfcColumn/Column.001" exists
+ And the object "IfcColumn/Column.002" does not exist
+
+Scenario: Edit array - multiple arrays
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ When I set the "Count" property to "3"
+ And I set the "Method" property to "Offset"
+ And I set the "X" property to "1"
+ And I click "CHECKMARK"
+ And I see "3 Items"
+ And I click "ADD"
+ And I click the "GREASEPENCIL" after the text "1 Items (Offset)"
+ And I set the "Count" property to "2"
+ And I set the "Method" property to "Offset"
+ And I set the "Y" property to "1"
+ And I click the "CHECKMARK" after the text "Count"
+ Then I see "3 Items"
+ And I see "2 Items"
+ Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column.003" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column.004" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column.005" is in the collection "IfcBuildingStorey/My Storey"
+ And the object "IfcColumn/Column" is at "0,0,0"
+ And the object "IfcColumn/Column.001" is at "1,0,0"
+ And the object "IfcColumn/Column.002" is at "2,0,0"
+ And the object "IfcColumn/Column.003" is at "0,1,0"
+ And the object "IfcColumn/Column.004" is at "1,1,0"
+ And the object "IfcColumn/Column.005" is at "2,1,0"
+ And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.003" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.004" dimensions are "0.5,0.6,3"
+ And the object "IfcColumn/Column.005" dimensions are "0.5,0.6,3"
+
+Scenario: Remove array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ And I set the "Count" property to "2"
+ And I set the "Method" property to "Offset"
+ And I set the "X" property to "1"
+ And I click "CHECKMARK"
+ When I click "X"
+ Then the object "IfcColumn/Column" exists
+ And the object "IfcColumn/Column.001" does not exist
+
+Scenario: Regenerate array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ And I set the "Count" property to "2"
+ And I click "CHECKMARK"
+ When I click "FILE_REFRESH"
+ Then the object "IfcColumn/Column" exists
+ And the object "IfcColumn/Column.001" exists
+
+Scenario: Apply array
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ And I set the "Count" property to "2"
+ And I click "CHECKMARK"
+ When I click "CHECKMARK"
+ Then the object "IfcColumn/Column" exists
+ And the object "IfcColumn/Column.001" exists
+ And I see "No Array Found"
+
+Scenario: Select array parent
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ And I set the "Count" property to "2"
+ And I click "CHECKMARK"
+ When the object "IfcColumn/Column.001" is selected
+ And I click "OBJECT_DATA"
+ Then the object "IfcColumn/Column" is selected
+ And the object "IfcColumn/Column.001" is not selected
+
+Scenario: Select all array objects
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
+ And I add the construction type
+ And the object "IfcColumn/Column" is selected
+ And I look at the "Array" panel
+ And I click "ADD"
+ And I click "GREASEPENCIL"
+ And I set the "Count" property to "2"
+ And I click "CHECKMARK"
+ When the object "IfcColumn/Column.001" is selected
+ And I click "RESTRICT_SELECT_OFF"
+ Then the object "IfcColumn/Column" is selected
+ And the object "IfcColumn/Column.001" is selected
diff --git a/src/bonsai/test/bim/feature/spatial.feature b/src/bonsai/test/bim/feature/spatial.feature
index 2b3020ea23..3ed7a80b94 100644
--- a/src/bonsai/test/bim/feature/spatial.feature
+++ b/src/bonsai/test/bim/feature/spatial.feature
@@ -74,6 +74,50 @@ Scenario: Assign container
When I click "CHECKMARK"
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
+Scenario: Assign container - assign an aggregate which also affects children
+ 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 the object "IfcWall/Cube" is selected
+ When I press "bim.add_aggregate"
+ Then the object "IfcElementAssembly/Default_Name" exists
+ And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey"
+ When I look at the "Spatial Decomposition" panel
+ And I select the "My Site" item in the "BIM_UL_containers_manager" list
+ And I click "Set Default"
+ And the object "IfcWall/Cube" is selected
+ And I look at the "Spatial Container" panel
+ And I click "GREASEPENCIL"
+ And I click "CHECKMARK"
+ Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
+ And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site"
+
+Scenario: Assign container - assign a child which also affects parents
+ 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 the object "IfcWall/Cube" is selected
+ When I press "bim.add_aggregate"
+ Then the object "IfcElementAssembly/Default_Name" exists
+ And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey"
+ When I look at the "Spatial Decomposition" panel
+ And I select the "My Site" item in the "BIM_UL_containers_manager" list
+ And I click "Set Default"
+ And the object "IfcElementAssembly/Default_Name" is selected
+ And I look at the "Spatial Container" panel
+ And I click "GREASEPENCIL"
+ And I click "CHECKMARK"
+ Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
+ And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site"
+
Scenario: Copy to container
Given an empty IFC project
And I add a cube
diff --git a/src/bonsai/test/bim/feature/type.feature b/src/bonsai/test/bim/feature/type.feature
index 1efefef446..9bae601b30 100644
--- a/src/bonsai/test/bim/feature/type.feature
+++ b/src/bonsai/test/bim/feature/type.feature
@@ -110,7 +110,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa
And the object "IfcWall/Unnamed" has a "100" thick layered material containing the material "Default"
And the object "IfcWall/Unnamed" dimensions are ".5,.1,.5"
-Scenario: Assign type - assign to a different type with a material layer set
+Scenario: Assign type - assign to a different type with a LAYER2 material layer set
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
@@ -149,6 +149,23 @@ Scenario: Assign type - assign to a different type with a material layer set
Then the object "IfcWall/Cube" has a "200" thick layered material containing the material "Default"
And the object "IfcWall/Cube" dimensions are "1,.2,1"
+Scenario: Assign type - assign to a different type with a LAYER3 material layer set
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ When I press "bim.add_occurrence"
+ Then the object "IfcSlab/Slab" is an "IfcSlab"
+ And the object "IfcSlab/Slab" dimensions are "1,1,0.2"
+ And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
+ And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
+ When I look at the "Type" panel
+ And I click "GREASEPENCIL"
+ And I set the "relating_type" property to "FLR300"
+ And I click "CHECKMARK"
+ Then the object "IfcSlab/Slab" dimensions are "1,1,0.3"
+
Scenario: Assign type - assign to a type with a material profile set
Given an empty IFC project
And I add a cube
diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py
index 3cf7589420..057ee9ffab 100644
--- a/src/bonsai/test/core/bootstrap.py
+++ b/src/bonsai/test/core/bootstrap.py
@@ -137,6 +137,13 @@ def misc():
prophet.verify()
+@pytest.fixture
+def model():
+ prophet = Prophecy(bonsai.core.tool.Model)
+ yield prophet
+ prophet.verify()
+
+
@pytest.fixture
def nest():
prophet = Prophecy(bonsai.core.tool.Nest)
diff --git a/src/bonsai/test/core/test_spatial.py b/src/bonsai/test/core/test_spatial.py
index 0ae9695ea2..ddc6116fdf 100644
--- a/src/bonsai/test/core/test_spatial.py
+++ b/src/bonsai/test/core/test_spatial.py
@@ -38,16 +38,19 @@ class TestDereferenceStructure:
class TestAssignContainer:
def test_run(self, ifc, collector, spatial):
- spatial.can_contain("container", "element_obj").should_be_called().will_return(True)
- ifc.get_entity("element_obj").should_be_called().will_return("element")
- ifc.run(
- "spatial.assign_container", products=["element"], relating_structure="container"
- ).should_be_called().will_return("rel")
- spatial.disable_editing("element_obj").should_be_called()
- collector.assign("element_obj").should_be_called()
- assert (
- subject.assign_container(ifc, collector, spatial, container="container", element_obj="element_obj") == "rel"
- )
+ ifc.get_entity("obj").should_be_called().will_return("element")
+ spatial.get_root_element("element").should_be_called().will_return("aggregate")
+ spatial.get_decomposition("aggregate").should_be_called().will_return(["element", "element2"])
+ spatial.can_contain("container", "aggregate").should_be_called().will_return(True)
+ ifc.run("spatial.assign_container", products=["aggregate"], relating_structure="container").should_be_called()
+ spatial.disable_editing("obj").should_be_called()
+ ifc.get_object("aggregate").should_be_called().will_return("aggregate_obj")
+ ifc.get_object("element").should_be_called().will_return("obj")
+ ifc.get_object("element2").should_be_called().will_return("obj2")
+ collector.assign("aggregate_obj").should_be_called()
+ collector.assign("obj").should_be_called()
+ collector.assign("obj2").should_be_called()
+ subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"])
class TestEnableEditingContainer:
@@ -82,7 +85,7 @@ class TestCopyToContainer:
spatial.duplicate_object_and_data("obj").should_be_called().will_return("new_obj")
spatial.set_relative_object_matrix("new_obj", "to_container_obj", "matrix").should_be_called()
spatial.run_root_copy_class(obj="new_obj").should_be_called()
- spatial.run_spatial_assign_container(container="to_container", element_obj="new_obj").should_be_called()
+ spatial.run_spatial_assign_container(container="to_container", objs=["new_obj"]).should_be_called()
spatial.disable_editing("obj").should_be_called()
@@ -97,7 +100,7 @@ class TestCopyToContainer:
spatial.duplicate_object_and_data("obj").should_be_called().will_return("new_obj")
spatial.set_relative_object_matrix("new_obj", "to_container_obj", "matrix").should_be_called()
spatial.run_root_copy_class(obj="new_obj").should_be_called()
- spatial.run_spatial_assign_container(container="to_container", element_obj="new_obj").should_be_called()
+ spatial.run_spatial_assign_container(container="to_container", objs=["new_obj"]).should_be_called()
spatial.disable_editing("obj").should_be_called()
diff --git a/src/bonsai/test/core/test_type.py b/src/bonsai/test/core/test_type.py
index 0987ab3d9e..e46cf8168f 100644
--- a/src/bonsai/test/core/test_type.py
+++ b/src/bonsai/test/core/test_type.py
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see .
import bonsai.core.type as subject
-from test.core.bootstrap import ifc, model, type, geometry
+from test.core.bootstrap import geometry, ifc, model, type
class TestAssignType:
diff --git a/src/bonsai/test/core/test_unit.py b/src/bonsai/test/core/test_unit.py
index a7d73c755f..1c328fe84c 100644
--- a/src/bonsai/test/core/test_unit.py
+++ b/src/bonsai/test/core/test_unit.py
@@ -22,119 +22,91 @@ from test.core.bootstrap import ifc, unit
class TestAssignSceneUnits:
def test_creating_and_assigning_metric_units(self, ifc, unit):
- unit.is_scene_unit_metric().should_be_called().will_return(True)
- unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix")
- unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix")
- unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix")
- unit.add_mass_and_time_units().should_be_called().will_return(True)
- unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO")
- unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None)
-
- ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return(
+ unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
+ unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
+ unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name")
+ unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name")
+ unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name")
+ unit.is_si_unit("length_name").should_be_called().will_return(True)
+ unit.is_si_unit("area_name").should_be_called().will_return(True)
+ unit.is_si_unit("volume_name").should_be_called().will_return(True)
+ unit.is_si_unit("mass_name").should_be_called().will_return(True)
+ unit.is_si_unit("time_name").should_be_called().will_return(True)
+ unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
+ unit.get_scene_unit_si_prefix("area_name").should_be_called().will_return("area_prefix")
+ unit.get_scene_unit_si_prefix("volume_name").should_be_called().will_return("volume_prefix")
+ unit.get_scene_unit_si_prefix("mass_name").should_be_called().will_return("mass_prefix")
+ unit.get_scene_unit_si_prefix("time_name").should_be_called().will_return("time_prefix")
+ ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
"lengthunit"
)
- ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit")
- ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return(
+ ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="area_prefix").should_be_called().will_return(
+ "areaunit"
+ )
+ ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="volume_prefix").should_be_called().will_return(
"volumeunit"
)
- ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit")
- ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
- ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
-
+ ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="mass_prefix").should_be_called().will_return(
+ "massunit"
+ )
+ ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix="time_prefix").should_be_called().will_return(
+ "timeunit"
+ )
ifc.run(
- "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
+ "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
- def test_creating_and_assigning_metric_units_without_mass_and_time(self, ifc, unit):
- unit.is_scene_unit_metric().should_be_called().will_return(True)
- unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("CENTI")
- unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("CENTI")
- unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("CENTI")
- unit.add_mass_and_time_units().should_be_called().will_return(False)
- ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="CENTI").should_be_called().will_return("lengthunit")
- ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="CENTI").should_be_called().will_return("areaunit")
- ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="CENTI").should_be_called().will_return("volumeunit")
- ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
- ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
+ def test_creating_and_assigning_only_specified_units(self, ifc, unit):
+ unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
+ unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return(None)
+ unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None)
+ unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None)
+ unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None)
+ unit.is_si_unit("length_name").should_be_called().will_return(True)
+ unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
+ ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
+ "lengthunit"
+ )
+ ifc.run("unit.assign_unit", units=["lengthunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_imperial_units(self, ifc, unit):
- unit.is_scene_unit_metric().should_be_called().will_return(False)
- unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot")
- unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot")
- unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot")
- unit.add_mass_and_time_units().should_be_called().will_return(True)
- unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound")
- unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND")
-
- ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit")
- ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit")
- ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit")
- ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit")
- ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
- ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
-
+ unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
+ unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
+ unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name")
+ unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name")
+ unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name")
+ unit.is_si_unit("length_name").should_be_called().will_return(False)
+ unit.is_si_unit("area_name").should_be_called().will_return(False)
+ unit.is_si_unit("volume_name").should_be_called().will_return(False)
+ unit.is_si_unit("mass_name").should_be_called().will_return(False)
+ unit.is_si_unit("time_name").should_be_called().will_return(False)
+ ifc.run("unit.add_conversion_based_unit", name="length_name").should_be_called().will_return("lengthunit")
+ ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit")
+ ifc.run("unit.add_conversion_based_unit", name="volume_name").should_be_called().will_return("volumeunit")
+ ifc.run("unit.add_conversion_based_unit", name="mass_name").should_be_called().will_return("massunit")
+ ifc.run("unit.add_conversion_based_unit", name="time_name").should_be_called().will_return("timeunit")
ifc.run(
- "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
+ "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
- def test_creating_and_assigning_imperial_units_without_mass_and_time(self, ifc, unit):
- unit.is_scene_unit_metric().should_be_called().will_return(False)
- unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("yard")
- unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square yard")
- unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic yard")
- unit.add_mass_and_time_units().should_be_called().will_return(False)
- ifc.run("unit.add_conversion_based_unit", name="yard").should_be_called().will_return("lengthunit")
- ifc.run("unit.add_conversion_based_unit", name="square yard").should_be_called().will_return("areaunit")
- ifc.run("unit.add_conversion_based_unit", name="cubic yard").should_be_called().will_return("volumeunit")
- ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
- ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
- subject.assign_scene_units(ifc, unit)
-
- def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit):
- unit.is_scene_unit_metric().should_be_called().will_return(True)
- unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI")
- unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None)
- unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None)
- unit.add_mass_and_time_units().should_be_called().will_return(True)
- unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION")
- unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne")
- unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION")
- unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute")
-
- ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="MILLI").should_be_called().will_return("lengthunit")
- ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=None).should_be_called().will_return("areaunit")
- ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=None).should_be_called().will_return("volumeunit")
- ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit")
- ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit")
- ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
-
- ifc.run(
- "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
- ).should_be_called()
- subject.assign_scene_units(ifc, unit)
-
- def test_creating_imperial_units_with_conversion_based_units(self, ifc, unit):
- unit.is_scene_unit_metric().should_be_called().will_return(False)
- unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch")
- unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch")
- unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch")
- unit.add_mass_and_time_units().should_be_called().will_return(True)
- unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce")
- unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour")
-
- ifc.run("unit.add_conversion_based_unit", name="inch").should_be_called().will_return("lengthunit")
- ifc.run("unit.add_conversion_based_unit", name="square inch").should_be_called().will_return("areaunit")
- ifc.run("unit.add_conversion_based_unit", name="cubic inch").should_be_called().will_return("volumeunit")
- ifc.run("unit.add_conversion_based_unit", name="ounce").should_be_called().will_return("massunit")
- ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("timeunit")
- ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
-
- ifc.run(
- "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
- ).should_be_called()
+ def test_creating_both_metric_and_imperial_units(self, ifc, unit):
+ # I know British doctors measure with stones so...
+ unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
+ unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
+ unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None)
+ unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None)
+ unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None)
+ unit.is_si_unit("length_name").should_be_called().will_return(True)
+ unit.is_si_unit("area_name").should_be_called().will_return(False)
+ unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
+ ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
+ "lengthunit"
+ )
+ ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit")
+ ifc.run("unit.assign_unit", units=["lengthunit", "areaunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
diff --git a/src/bonsai/test/tool/test_geometry.py b/src/bonsai/test/tool/test_geometry.py
index 34300f2405..424e1fd876 100644
--- a/src/bonsai/test/tool/test_geometry.py
+++ b/src/bonsai/test/tool/test_geometry.py
@@ -323,8 +323,8 @@ class TestRecordObjectPosition(NewFile):
obj = bpy.data.objects.new("Object", None)
props = tool.Blender.get_object_bim_props(obj)
subject.record_object_position(obj)
- assert props.location_checksum == repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes())
- assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
+ assert props.location_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
+ assert props.rotation_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
class TestRemoveConnection(NewFile):
diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py
index 299e615f2f..005f370b01 100644
--- a/src/bonsai/test/tool/test_spatial.py
+++ b/src/bonsai/test/tool/test_spatial.py
@@ -43,9 +43,7 @@ class TestCanContain(NewFile):
structure_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(structure, structure_obj)
element = ifc.createIfcWall()
- element_obj = bpy.data.objects.new("Object", None)
- tool.Ifc.link(element, element_obj)
- assert subject.can_contain(structure, element_obj) is True
+ assert subject.can_contain(structure, element) is True
def test_a_spatial_structure_element_can_contain_an_element_ifc2x3(self):
ifc = ifcopenshell.file(schema="IFC2X3")
@@ -54,9 +52,7 @@ class TestCanContain(NewFile):
structure_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(structure, structure_obj)
element = ifc.createIfcWall()
- element_obj = bpy.data.objects.new("Object", None)
- tool.Ifc.link(element, element_obj)
- assert subject.can_contain(structure, element_obj) is True
+ assert subject.can_contain(structure, element) is True
def test_a_spatial_zone_element_cannot_contain_an_element(self):
ifc = ifcopenshell.file()
@@ -65,14 +61,7 @@ class TestCanContain(NewFile):
structure_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(structure, structure_obj)
element = ifc.createIfcWall()
- element_obj = bpy.data.objects.new("Object", None)
- tool.Ifc.link(element, element_obj)
- assert subject.can_contain(structure, element_obj) is False
-
- def test_unlinked_elements_cannot_contain_anything(self):
- structure_obj = bpy.data.objects.new("Object", None)
- element_obj = bpy.data.objects.new("Object", None)
- assert subject.can_contain(structure_obj, element_obj) is False
+ assert subject.can_contain(structure, element) is False
def test_a_non_spatial_element_cannot_contain_anything(self):
ifc = ifcopenshell.file()
@@ -81,9 +70,7 @@ class TestCanContain(NewFile):
structure_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(structure, structure_obj)
element = ifc.createIfcWall()
- element_obj = bpy.data.objects.new("Object", None)
- tool.Ifc.link(element, element_obj)
- assert subject.can_contain(structure, element_obj) is False
+ assert subject.can_contain(structure, element) is False
def test_a_non_element_cannot_be_contained(self):
ifc = ifcopenshell.file()
@@ -92,9 +79,7 @@ class TestCanContain(NewFile):
structure_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(structure, structure_obj)
element = ifc.createIfcTask()
- element_obj = bpy.data.objects.new("Object", None)
- tool.Ifc.link(element, element_obj)
- assert subject.can_contain(structure, element_obj) is False
+ assert subject.can_contain(structure, element) is False
def test_other_non_elements_that_have_a_contained_in_structure_attribute_can_be_contained(self):
ifc = ifcopenshell.file()
@@ -103,9 +88,7 @@ class TestCanContain(NewFile):
structure_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(structure, structure_obj)
element = ifc.createIfcGrid()
- element_obj = bpy.data.objects.new("Object", None)
- tool.Ifc.link(element, element_obj)
- assert subject.can_contain(structure, element_obj) is True
+ assert subject.can_contain(structure, element) is True
class TestCanReference(NewFile):
diff --git a/src/bonsai/test/tool/test_unit.py b/src/bonsai/test/tool/test_unit.py
index df3acc1b3e..392650da71 100644
--- a/src/bonsai/test/tool/test_unit.py
+++ b/src/bonsai/test/tool/test_unit.py
@@ -434,18 +434,6 @@ class TestImportUnits(NewFile):
assert second_prop.ifc_class == "IfcSIUnit"
-class TestIsSceneUnitMetric(NewFile):
- def test_run(self):
- assert bpy.context.scene
- props = bpy.context.scene.unit_settings
- props.system = "METRIC"
- assert subject.is_scene_unit_metric() is True
- props.system = "IMPERIAL"
- assert subject.is_scene_unit_metric() is False
- props.system = "NONE"
- assert subject.is_scene_unit_metric() is True
-
-
class TestIsUnitClass:
def test_run(self):
ifc = ifcopenshell.file()
diff --git a/src/examples/ifc_curve_rebar.cpp b/src/examples/ifc_curve_rebar.cpp
index 81a705dc5e..0f66f7b45a 100644
--- a/src/examples/ifc_curve_rebar.cpp
+++ b/src/examples/ifc_curve_rebar.cpp
@@ -31,6 +31,9 @@
#include "ifcparse/Ifc2x3.h"
#include "ifcparse/IfcHierarchyHelper.h"
+#include
+const static double PI = boost::math::constants::pi();
+
typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
@@ -41,7 +44,7 @@ void create_curve_rebar(IfcHierarchyHelper& file)
int R = 3 * dia;
int length = 12 * dia;
- double crossSectionarea = M_PI * (dia / 2) * 2;
+ double crossSectionarea = PI * dia * dia / 4;
IfcSchema::IfcReinforcingBar* rebar = new IfcSchema::IfcReinforcingBar(
guid(), 0, S("test"), null,
null, 0, 0,
diff --git a/src/ifcconvert/CMakeLists.txt b/src/ifcconvert/CMakeLists.txt
index a5e16180cb..af46bc7dfe 100644
--- a/src/ifcconvert/CMakeLists.txt
+++ b/src/ifcconvert/CMakeLists.txt
@@ -15,6 +15,7 @@ target_link_libraries(
IfcGeom
IfcParse
Serializers
+ ${kernel_libraries}
${OpenCASCADE_LIBRARIES}
${Boost_LIBRARIES}
${HDF5_LIBRARIES}
diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h
index eb405a9e7c..febd789f6b 100644
--- a/src/ifcgeom/ConversionSettings.h
+++ b/src/ifcgeom/ConversionSettings.h
@@ -452,6 +452,12 @@ namespace ifcopenshell {
static constexpr bool defaultvalue = false;
};
+ struct MakeVolume : public SettingBase {
+ static constexpr const char* const name = "make-volume";
+ static constexpr const char* const description = "Try to isolate and fix a valid volume from non-manifold elements prior to opening subtraction";
+ static constexpr bool defaultvalue = false;
+ };
+
struct DeferProcessingFirstElement : public SettingBase {
static constexpr const char* const name = "defer-processing-first-element";
static constexpr const char* const description = "Don't process first element in Iterator::initialize call()";
@@ -647,7 +653,7 @@ namespace ifcopenshell {
};
class Settings : public SettingsContainer<
- std::tuple
+ std::tuple
>
{};
}
diff --git a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp
index fcb390b6cc..a5a9d94149 100644
--- a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp
+++ b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp
@@ -29,6 +29,7 @@
#include "base_utils.h"
#include
+#include
namespace {
struct opening_sorter {
@@ -132,11 +133,24 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
parts.push_back(it3_shape);
}
- for (auto& entity_part : parts) {
+ for (auto entity_part : parts) {
bool is_manifold = util::is_manifold(entity_part);
if (!is_manifold) {
- Logger::Warning("Non-manifold first operand");
+ if (settings_.get().get()) {
+ BOPAlgo_MakerVolume mv;
+ mv.AddArgument(entity_part);
+ mv.SetAvoidInternalShapes(true);
+ try {
+ mv.Perform();
+ entity_part = mv.Shape();
+ Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand");
+ } catch (const Standard_Failure& e) {
+ Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity);
+ }
+ } else {
+ Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold");
+ }
}
TopoDS_Shape entity_part_result;
diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py
index 6850013c96..a5675a4dc2 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py
@@ -28,7 +28,7 @@ def _create_offset_curve_representation(
file: ifcopenshell.file, alignment: entity_instance, offsets: Sequence[entity_instance]
) -> None:
"""
- Create geometric representation for the alignment based on an IfcPolyline
+ Create geometric representation for the alignment based on an IfcOffsetByDistances curve
:param alignment: The alignment for which the representation is being created
:return: None
@@ -36,6 +36,11 @@ def _create_offset_curve_representation(
expected_type = "IfcAlignment"
if not alignment.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
+
+ expected_type = "IfcPointByDistanceExpression"
+ for offset in offsets:
+ if not offset.is_a(expected_type):
+ raise TypeError(f"Expected {expected_type} but got {offset.is_a()}")
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py
index b24fb16b29..6ac56af362 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py
@@ -39,7 +39,7 @@ def create_as_offset_curve(
:param file:
:param name: name assigned to IfcAlignment.Name
- :param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcOffsetCurveByDistances.
+ :param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcPointByDistanceExpression.
:param start_station: station value at the start of the alignment
:return: Returns an IfcAlignment
"""
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py
index b5029dc934..6e600eca9a 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py
@@ -100,15 +100,10 @@ class Usecase:
size = self.convert_si_to_unit(1)
points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0))
if self.polyline:
- # Only scale polyline if we have actual slope
- if self.x_angle and abs(self.x_angle) > 1e-6:
- points = [
- (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
- for p in self.polyline
- ]
- else:
- points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline]
-
+ points = [
+ (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
+ for p in self.polyline
+ ]
if self.file.schema == "IFC2X3":
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
else:
@@ -119,23 +114,21 @@ class Usecase:
else:
direction_ratios = (0.0, 0.0, 1.0)
+ offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative
extrusion_direction = self.file.createIfcDirection(direction_ratios)
+ if self.direction_sense == "NEGATIVE":
+ direction_ratios = tuple(-n for n in direction_ratios)
+ extrusion_direction = self.file.createIfcDirection(direction_ratios)
- # Calculate depth based on extrusion angle
- extrusion_angle = abs(self.x_angle) if self.x_angle else 0
- if extrusion_angle > 1e-6:
- perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle))
- perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle))
- else:
- perpendicular_depth = self.convert_si_to_unit(self.depth)
- perpendicular_offset = self.convert_si_to_unit(self.offset)
-
+ perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle))
+ perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle))
position = None
+ # default position for IFC2X3 where .Position is not optional
if self.file.schema == "IFC2X3" or self.offset != 0:
position_vector = (
- direction_ratios[0] * perpendicular_offset,
- direction_ratios[1] * perpendicular_offset,
- direction_ratios[2] * perpendicular_offset,
+ offset_direction[0] * perpendicular_offset,
+ offset_direction[1] * perpendicular_offset,
+ offset_direction[2] * perpendicular_offset,
)
position = self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint(position_vector),
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py
index 69f9a82fa7..ff62bae474 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py
@@ -85,6 +85,7 @@ class Usecase:
def create_item(self) -> ifcopenshell.entity_instance:
length = self.convert_si_to_unit(self.settings["length"])
thickness = self.convert_si_to_unit(self.settings["thickness"])
+ thickness *= 1 / cos(self.settings["x_angle"])
if self.settings["direction_sense"] == "NEGATIVE":
thickness *= -1
points = (
@@ -112,7 +113,7 @@ class Usecase:
self.file.createIfcDirection((1.0, 0.0, 0.0)),
),
extrusion_direction,
- self.convert_si_to_unit(self.settings["height"]),
+ self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])),
)
if self.settings["booleans"]:
extrusion = self.apply_booleans(extrusion)
diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py
index abcba23a0f..17051d6917 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/selector.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py
@@ -144,7 +144,7 @@ format_grammar = lark.Lark(
| mul_div "*" function -> multiply
| mul_div "/" function -> divide
- function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | NUMBER | "(" expression ")"
+ function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | SIGNED_NUMBER | "(" expression ")"
variable: "{{" query_path "}}"
query_path: /[^}]+/
diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py
index 0b90f3b278..959cacd02d 100644
--- a/src/ifcopenshell-python/test/util/test_selector.py
+++ b/src/ifcopenshell-python/test/util/test_selector.py
@@ -54,6 +54,7 @@ class TestFormat:
def test_number_formatting(self):
assert subject.format("round(123, 5)") == "125"
assert subject.format('round("123", 5)') == "125"
+ assert subject.format('round(-123, 5)') == "-125"
assert subject.format("int(123.123)") == "123"
assert subject.format("int(123)") == "123"
assert subject.format("number(123)") == "123"
diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json
index 35d03c74fa..647b9d534e 100644
--- a/src/ifctester/webapp/package-lock.json
+++ b/src/ifctester/webapp/package-lock.json
@@ -2851,9 +2851,9 @@
}
},
"node_modules/tar": {
- "version": "7.5.6",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz",
- "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==",
+ "version": "7.5.7",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
+ "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt
index 47420d9817..16e3a86565 100644
--- a/src/ifcwrap/CMakeLists.txt
+++ b/src/ifcwrap/CMakeLists.txt
@@ -140,6 +140,7 @@ else()
target_link_libraries(ifcopenshell_wrapper PRIVATE ${IFCOPENSHELL_LIBRARIES} ${LIBSVGFILL})
endif()
target_link_libraries(ifcopenshell_wrapper PRIVATE ${CGAL_LIBRARIES})
+target_link_libraries(ifcopenshell_wrapper PRIVATE IfcGeom ${kernel_libraries})
if((NOT WIN32) AND BUILD_SHARED_LIBS)
SET_INSTALL_RPATHS(ifcopenshell_wrapper "${IFCDIRS};${OCC_LIBRARY_DIR}")
endif()