Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2026-02-07 13:30:19 +01:00
71 changed files with 1363 additions and 1362 deletions
+5
View File
@@ -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
-1
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ dependencies = [
"black==26.1.0",
"ruff==0.14.14",
"poethepoet",
"gersemi==0.25.1",
"gersemi==0.25.4",
]
[tool.black]
+22
View File
@@ -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
+40 -20
View File
@@ -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:
@@ -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)
@@ -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,
@@ -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
@@ -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
+22 -165
View File
@@ -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"}
+30 -52
View File
@@ -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
@@ -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)
+60 -77
View File
@@ -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):
@@ -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))
@@ -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,
+38 -21
View File
@@ -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"
@@ -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"]
@@ -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")
@@ -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",
+83 -240
View File
@@ -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"
+1 -1
View File
@@ -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")
+45 -120
View File
@@ -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)
@@ -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"},
+1 -1
View File
@@ -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")
+67 -47
View File
@@ -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
@@ -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"
+44 -37
View File
@@ -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"}
@@ -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):
+3 -4
View File
@@ -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
@@ -37,6 +37,7 @@ classes = (
operator.EditZone,
operator.EnableEditingSystem,
operator.EnableEditingZone,
operator.EstablishPathDirection,
operator.HidePorts,
operator.LoadSystems,
operator.LoadZones,
@@ -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"
+5 -1
View File
@@ -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):
+9 -2
View File
@@ -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(
+35 -3
View File
@@ -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)
+16 -9
View File
@@ -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
+11 -7
View File
@@ -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:
+43 -5
View File
@@ -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)
+2
View File
@@ -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):
+8 -14
View File
@@ -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:
+45 -67
View File
@@ -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
+2 -3
View File
@@ -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:
+7 -14
View File
@@ -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
+4 -1
View File
@@ -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
+31 -148
View File
@@ -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
+23 -22
View File
@@ -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,
+21 -5
View File
@@ -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
-5
View File
@@ -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)
+1
View File
@@ -1,6 +1,7 @@
[pytest]
markers =
aggregate
array
attribute
boolean
boundary
+3 -2
View File
@@ -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__":
+276
View File
@@ -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
@@ -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
+18 -1
View File
@@ -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
+7
View File
@@ -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)
+15 -12
View File
@@ -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()
+1 -1
View File
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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:
+70 -98
View File
@@ -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)
+2 -2
View File
@@ -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):
+6 -23
View File
@@ -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):
-12
View File
@@ -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()
+4 -1
View File
@@ -31,6 +31,9 @@
#include "ifcparse/Ifc2x3.h"
#include "ifcparse/IfcHierarchyHelper.h"
#include <boost/math/constants/constants.hpp>
const static double PI = boost::math::constants::pi<double>();
typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
@@ -41,7 +44,7 @@ void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& 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,
+1
View File
@@ -15,6 +15,7 @@ target_link_libraries(
IfcGeom
IfcParse
Serializers
${kernel_libraries}
${OpenCASCADE_LIBRARIES}
${Boost_LIBRARIES}
${HDF5_LIBRARIES}
+7 -1
View File
@@ -452,6 +452,12 @@ namespace ifcopenshell {
static constexpr bool defaultvalue = false;
};
struct MakeVolume : public SettingBase<MakeVolume, bool> {
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<DeferProcessingFirstElement, bool, true> {
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<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset>
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
>
{};
}
@@ -29,6 +29,7 @@
#include "base_utils.h"
#include <BRepPrimAPI_MakeRevol.hxx>
#include <BOPAlgo_MakerVolume.hxx>
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<settings::MakeVolume>().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;
@@ -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)
@@ -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
"""
@@ -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),
@@ -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)
@@ -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: /[^}]+/
@@ -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"
+3 -3
View File
@@ -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": {
+1
View File
@@ -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()