From 79ee88da5c039270b5af58fcf0b7cdb514e95ea1 Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 27 Jul 2026 09:57:45 +0200 Subject: [PATCH] Add auto-detect space height from elements above Space height is now auto-detected using IFC geometry directly (ifcopenshell.geom.create_shape + get_shape_bottom/top_elevation) instead of Blender object bounding boxes. This fixes height detection when the slab above is not loaded in Blender. Detection priority: 1. IfcRelConnectsElements(TOP) connections on bounding walls 2. IfcSlab / IfcRoof elements above with XY overlap to space polygon 3. Minimum wall top Z of bounding walls 4. Fallback to space_height property (default 3m) Added space_height and force_space_height properties to BIMSpatialDecompositionProperties. The height field is synced to the active space's height via active_object_callback (msgbus), not in draw(). Added ApplySpaceHeightToSelection operator to modify IfcExtrudedAreaSolid.Depth in place without regenerating footprint. bounding_walls changed from list[tuple[element, obj]] to list[entity_instance] since Blender objects are no longer needed. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/handler.py | 19 ++ .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/space.py | 46 +++++ src/bonsai/bonsai/bim/module/spatial/prop.py | 14 ++ .../bonsai/bim/module/spatial/workspace.py | 5 + src/bonsai/bonsai/core/spatial.py | 24 ++- src/bonsai/bonsai/tool/spatial.py | 172 ++++++++++++++++-- src/bonsai/test/tool/test_spatial.py | 127 +++++++++++++ 8 files changed, 389 insertions(+), 19 deletions(-) diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index c8502a4c0e..926346ef69 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -59,6 +59,7 @@ from bonsai.bim.module.model.decorator import ( ) from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator from bonsai.bim.module.nest.decorator import NestDecorator +from bonsai.tool.spatial import install_geom_cache_handlers, uninstall_geom_cache_handlers cwd = os.path.dirname(os.path.realpath(__file__)) global_subscription_owner = object() @@ -121,9 +122,25 @@ def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) - def active_object_callback(): refresh_ui_data() update_bim_tool_props() + update_spatial_tool_props() tool.Geometry.sync_item_positions() +def update_spatial_tool_props(): + """Sync ``BIMSpatialDecompositionProperties.space_height`` with the + active object's height when it is an ``IfcSpace``, otherwise reset to + the 3m default. Called from the msgbus active-object callback so Scene + property writes happen outside ``draw()``.""" + obj = tool.Blender.get_active_object() + props = tool.Spatial.get_spatial_props() + if obj: + element = tool.Ifc.get_entity(obj) + if element and element.is_a("IfcSpace"): + props.space_height = obj.dimensions.z + return + props.space_height = 3 + + def update_bim_tool_props(): """Selection-driven BIM Tool sync: re-target user-intent enums (ifc_class, relating_type_id) AND refresh header values @@ -528,6 +545,7 @@ def _install_viewport_overlays() -> None: ArrayPreviewDecorator.uninstall() ArraySelectionHighlightDecorator.uninstall() uninstall_decorator_cache_handlers() + uninstall_geom_cache_handlers() try: if georeference_props.should_visualise: GeoreferenceDecorator.install(bpy.context) @@ -570,6 +588,7 @@ def _install_viewport_overlays() -> None: ArrayPreviewDecorator.install(bpy.context) finally: install_decorator_cache_handlers() + install_geom_cache_handlers() @persistent diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index d59cd6d260..b32eb522b1 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -178,6 +178,7 @@ classes = ( covering.RegenSelectedCoveringObject, space.ToggleSpaceVisibility, space.ToggleHideSpaces, + space.ApplySpaceHeightToSelection, mep.FitFlowSegments, mep.RegenerateDistributionElement, prop.SnapMousePoint, diff --git a/src/bonsai/bonsai/bim/module/model/space.py b/src/bonsai/bonsai/bim/module/model/space.py index baa7401358..d4e0716a56 100644 --- a/src/bonsai/bonsai/bim/module/model/space.py +++ b/src/bonsai/bonsai/bim/module/model/space.py @@ -18,7 +18,9 @@ import bpy +import ifcopenshell.util.unit +import bonsai.core.geometry as core_geometry import bonsai.core.spatial as core import bonsai.tool as tool @@ -115,3 +117,47 @@ class ToggleHideSpaces(bpy.types.Operator): def execute(self, context): core.toggle_hide_spaces(tool.Ifc, tool.Spatial) return {"FINISHED"} + + +class ApplySpaceHeightToSelection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.apply_space_height_to_selection" + bl_label = "Apply Space Height To Selection" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Apply the space height value to all selected spaces without regenerating their footprint" + + @classmethod + def poll(cls, context): + selected_spaces = [ + obj + for obj in context.selected_objects + if (element := tool.Ifc.get_entity(obj)) and element.is_a("IfcSpace") + ] + if not selected_spaces: + cls.poll_message_set("No spaces selected.") + return False + return True + + def _execute(self, context): + ifc_file = tool.Ifc.get() + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + depth_ifc = tool.Spatial.get_spatial_props().space_height / si_conversion + total = 0 + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcSpace"): + continue + body = tool.Geometry.get_body_representation(element) + if not body: + continue + extrusion = tool.Model.get_extrusion(body) + if not extrusion: + continue + extrusion.Depth = depth_ifc + core_geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=body, + ) + total += 1 + self.report({"INFO"}, f"Height applied to {total} spaces.") diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index b0d393b265..fbdd05a7c0 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -24,6 +24,7 @@ from bpy.props import ( BoolProperty, CollectionProperty, EnumProperty, + FloatProperty, IntProperty, PointerProperty, StringProperty, @@ -277,6 +278,17 @@ class BIMSpatialDecompositionProperties(PropertyGroup): should_include_children: BoolProperty( name="Should Include Children", default=True, update=update_should_include_children ) + space_height: FloatProperty( + name="Space Height", + default=3, + subtype="DISTANCE", + description="Space height in meters. Auto-detected on generation unless forced. Used as fallback.", + ) + force_space_height: BoolProperty( + name="Force Height", + default=False, + description="If enabled, uses the height value directly and skips auto-detection", + ) if TYPE_CHECKING: is_locked: bool @@ -294,6 +306,8 @@ class BIMSpatialDecompositionProperties(PropertyGroup): subelement_class: str default_container: int should_include_children: bool + space_height: float + force_space_height: bool @property def active_container(self) -> Union[BIMContainer, None]: diff --git a/src/bonsai/bonsai/bim/module/spatial/workspace.py b/src/bonsai/bonsai/bim/module/spatial/workspace.py index cd5d02b2bb..7e11a1583c 100644 --- a/src/bonsai/bonsai/bim/module/spatial/workspace.py +++ b/src/bonsai/bonsai/bim/module/spatial/workspace.py @@ -83,9 +83,14 @@ class SpatialToolUI: @classmethod def draw_default_interface(cls, context): + spatial_props = tool.Spatial.get_spatial_props() row = cls.layout.row(align=True) row.prop(data=cls.model_props, property="rl3", text="RL") row = cls.layout.row(align=True) + row.prop(data=spatial_props, property="space_height", text="Height") + row.prop(data=spatial_props, property="force_space_height", text="", icon="PINNED") + row.operator("bim.apply_space_height_to_selection", text="", icon="COPYDOWN") + row = cls.layout.row(align=True) op_name = lambda op: op.get_rna_type().name if AuthoringData.data["active_class"] == "IfcWall" and context.selected_objects: add_layout_hotkey( diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 8843d48f2d..47616eff24 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -206,7 +206,7 @@ def generate_space( else: x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor() - space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y) + space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y) if isinstance(space_polygon, str): if space_polygon == "NO POLYGONS FOUND": @@ -220,6 +220,14 @@ def generate_space( else: assert space_polygon + props = spatial.get_spatial_props() + if props.force_space_height: + h = props.space_height + else: + auto_h = spatial.get_auto_space_height(space_polygon, z, bounding_walls) + if auto_h is not None and auto_h > 0: + h = auto_h + if element and element.is_a("IfcSpace"): spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True) else: @@ -248,11 +256,25 @@ def generate_spaces_from_walls( z = spatial.get_active_obj_z() h = spatial.get_active_obj_height() + bounding_walls = [ + element + for obj in spatial.get_selected_objects() + if (element := ifc.get_entity(obj)) and element.is_a("IfcWall") + ] + union = spatial.get_union_shape_from_selected_objects() + props = spatial.get_spatial_props() for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) + if props.force_space_height: + h = props.space_height + else: + auto_h = spatial.get_auto_space_height(poly, z, bounding_walls) + if auto_h is not None and auto_h > 0: + h = auto_h + name = "Space" + str(i) obj = spatial.create_object(name) diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 875023d34b..7a051a8c3b 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -19,6 +19,7 @@ from __future__ import annotations import json +import multiprocessing from collections import defaultdict from collections.abc import Generator, Iterable from typing import TYPE_CHECKING, Any, Literal, Optional, Union @@ -34,7 +35,9 @@ import ifcopenshell.util.classification import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.representation +import ifcopenshell.util.shape import ifcopenshell.util.shape_builder +import ifcopenshell.util.space import ifcopenshell.util.type import ifcopenshell.util.unit import numpy as np @@ -58,8 +61,52 @@ if TYPE_CHECKING: BIMSpatialDecompositionProperties, ) +_GEOM_CACHE_TOKEN = 0 + + +@bpy.app.handlers.persistent +def _bump_geom_cache_token(*args) -> None: + global _GEOM_CACHE_TOKEN + if len(args) >= 2: + depsgraph = args[1] + if depsgraph is not None and hasattr(depsgraph, "updates"): + if not any( + (getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False)) + and hasattr(u, "id") + and isinstance(u.id, bpy.types.Object) + for u in depsgraph.updates + ): + return + _GEOM_CACHE_TOKEN += 1 + + +def install_geom_cache_handlers() -> None: + for hook in ( + bpy.app.handlers.depsgraph_update_post, + bpy.app.handlers.undo_post, + bpy.app.handlers.redo_post, + bpy.app.handlers.load_post, + ): + if _bump_geom_cache_token not in hook: + hook.append(_bump_geom_cache_token) + + +def uninstall_geom_cache_handlers() -> None: + for hook in ( + bpy.app.handlers.depsgraph_update_post, + bpy.app.handlers.undo_post, + bpy.app.handlers.redo_post, + bpy.app.handlers.load_post, + ): + try: + hook.remove(_bump_geom_cache_token) + except ValueError: + pass + class Spatial(bonsai.core.tool.Spatial): + _geom_cache: dict = {} + @classmethod def get_spatial_props(cls) -> BIMSpatialDecompositionProperties: return bpy.context.scene.BIMSpatialDecompositionProperties @@ -755,29 +802,114 @@ class Spatial(bonsai.core.tool.Spatial): # HERE STARTS SPATIAL TOOL + @classmethod + def get_or_build_geom_cache(cls) -> dict: + """Build or return a cached dict of IFC element shapes for space generation. + + The cache is keyed on ``_GEOM_CACHE_TOKEN`` which is bumped by a + ``depsgraph_update_post`` handler when any Object geometry or transform + changes, and on undo/redo/load. This means the cache survives space + generations (which don't change Object geometry) but is correctly + invalidated when a user moves or edits a wall, slab, etc. + + :return: ``{"shapes": {id: {"verts": ndarray, "faces": ndarray, "bottom_z": float, "top_z": float}}, "token": int}`` + """ + global _GEOM_CACHE_TOKEN + cached = cls._geom_cache.get("current") + if cached and cached["token"] == _GEOM_CACHE_TOKEN: + return cached + + ifc_file = tool.Ifc.get() + include = [] + for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES: + include.extend(ifc_file.by_type(ifc_class)) + + settings = ifcopenshell.geom.settings() + settings.set("disable-opening-subtractions", True) + settings.set("use-world-coords", True) + + shapes = {} + iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=include) + if iterator.initialize(): + while True: + shape = iterator.get() + verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry) + faces = ifcopenshell.util.shape.get_faces(shape.geometry) + zs = verts[:, 2] + shapes[shape.id] = { + "verts": verts, + "faces": faces, + "bottom_z": float(zs.min()), + "top_z": float(zs.max()), + } + if not iterator.next(): + break + + cache = {"shapes": shapes, "token": _GEOM_CACHE_TOKEN} + cls._geom_cache["current"] = cache + return cache + @classmethod def is_bounding_class(cls, visible_element: ifcopenshell.entity_instance) -> bool: - for ifc_class in ["IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate"]: + for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES: if visible_element.is_a(ifc_class): return True return False @classmethod - def get_space_polygon_from_context_visible_objects( - cls, x: float, y: float - ) -> Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]]: - boundary_lines = cls.get_boundary_lines_from_context_visible_objects() - unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) - closed_polygons = shapely.polygonize(unioned_boundaries.geoms) - if not closed_polygons: - return "NO POLYGONS FOUND" - space_polygon = None - for polygon in closed_polygons.geoms: - if shapely.contains_xy(polygon, x, y): - space_polygon = shapely.force_3d(polygon) - if space_polygon is None: - return "NO POLYGON FOR POINT" - return space_polygon + def get_boundary_lines_from_ifc_elements( + cls, + cut_z: float, + ) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]: + """Generate boundary lines by bisecting IFC element geometry with a horizontal plane. + + Uses the class-level geometry cache (parallel iterator) instead of + iterating Blender visible objects. Works without any Blender objects + being loaded. + + :param cut_z: Z elevation of the cutting plane in world coordinates. + :return: (boundary_lines, bounding_elements) + """ + cache = cls.get_or_build_geom_cache() + return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z) + + @classmethod + def get_space_polygon_from_context_visible_objects(cls, x: float, y: float) -> tuple[ + Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]], + list[ifcopenshell.entity_instance], + ]: + props = tool.Model.get_model_props() + calculation_rl = props.rl3 + container = tool.Root.get_default_container() + container_obj = tool.Ifc.get_object(container) + cut_z = container_obj.matrix_world.translation.z + calculation_rl + + boundary_lines, bounding_elements = cls.get_boundary_lines_from_ifc_elements(cut_z) + polygon, _ = ifcopenshell.util.space.get_space_polygon(boundary_lines, x, y) + if isinstance(polygon, str): + return polygon, [] + return polygon, bounding_elements + + @classmethod + def get_auto_space_height( + cls, + space_polygon: shapely.Polygon, + base_z: float, + bounding_walls: list[ifcopenshell.entity_instance], + ) -> Optional[float]: + """Auto-detect space height from elements above using IFC geometry. + + Delegates to :func:`ifcopenshell.util.space.get_auto_space_height`. + + :param space_polygon: The space footprint polygon in world XY. + :param base_z: The space's base Z in world coordinates. + :param bounding_walls: List of IFC wall elements bounding the space. + :return: Detected height in SI (meters), or None if nothing found. + """ + cache = cls.get_or_build_geom_cache() + return ifcopenshell.util.space.get_auto_space_height( + tool.Ifc.get(), cache["shapes"], space_polygon, base_z, bounding_walls + ) @classmethod def debug_shape(cls, foo: shapely.Polygon) -> None: @@ -810,7 +942,9 @@ class Spatial(bonsai.core.tool.Spatial): bpy.context.view_layer.update() @classmethod - def get_boundary_lines_from_context_visible_objects(cls) -> list[shapely.LineString]: + def get_boundary_lines_from_context_visible_objects( + cls, + ) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]: props = tool.Model.get_model_props() calculation_rl = props.rl3 container = tool.Root.get_default_container() @@ -818,6 +952,7 @@ class Spatial(bonsai.core.tool.Spatial): cut_point = container_obj.matrix_world.translation.copy() + Vector((0, 0, calculation_rl)) cut_normal = Vector((0, 0, 1)) boundary_lines = [] + bounding_elements = [] for obj in bpy.context.visible_objects: visible_element = tool.Ifc.get_entity(obj) @@ -831,6 +966,7 @@ class Spatial(bonsai.core.tool.Spatial): ): continue + bounding_elements.append(visible_element) old_mesh = obj.data assert isinstance(old_mesh, bpy.types.Mesh) if visible_element.HasOpenings: @@ -870,7 +1006,7 @@ class Spatial(bonsai.core.tool.Spatial): start, end = tool.Drawing.extend_line(start, end, 0.05) boundary_lines.append(shapely.LineString([start, end])) - return boundary_lines + return boundary_lines, bounding_elements @classmethod def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh: diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 94b480f010..bd69f93334 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -312,3 +312,130 @@ class TestGenerateSpace(NewFile): bpy.ops.bim.generate_space() assert np.isclose(space.location.z, 5), f"Expected z=5, got {space.location.z}" + + def test_auto_space_height_from_slab_above(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + scene = bpy.context.scene + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) + wall_obj = bpy.data.objects["Cube"] + scene.collection.objects.link(wall_obj) + tool.Ifc.link(wall, wall_obj) + + slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4 + 4)) + slab_obj = bpy.data.objects["Cube.001"] + scene.collection.objects.link(slab_obj) + tool.Ifc.link(slab, slab_obj) + + scene.cursor.location = (0, 0, 0) + bpy.ops.bim.generate_space() + space = bpy.data.objects["IfcSpace/Space"] + assert np.isclose(space.dimensions.z, 4, atol=0.1), f"Expected height ~4, got {space.dimensions.z}" + + def test_forced_space_height(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + scene = bpy.context.scene + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) + wall_obj = bpy.data.objects["Cube"] + scene.collection.objects.link(wall_obj) + tool.Ifc.link(wall, wall_obj) + + scene.cursor.location = (0, 0, 0) + spatial_props = tool.Spatial.get_spatial_props() + spatial_props.force_space_height = True + spatial_props.space_height = 5 + bpy.ops.bim.generate_space() + space = bpy.data.objects["IfcSpace/Space"] + assert np.isclose(space.dimensions.z, 5, atol=0.1), f"Expected height 5, got {space.dimensions.z}" + + def test_auto_space_height_fallback_no_slab(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + scene = bpy.context.scene + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) + wall_obj = bpy.data.objects["Cube"] + scene.collection.objects.link(wall_obj) + tool.Ifc.link(wall, wall_obj) + + scene.cursor.location = (0, 0, 0) + spatial_props = tool.Spatial.get_spatial_props() + spatial_props.force_space_height = False + bpy.ops.bim.generate_space() + space = bpy.data.objects["IfcSpace/Space"] + assert space.dimensions.z > 0, f"Expected positive height, got {space.dimensions.z}" + + def test_apply_space_height_to_selection(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + scene = bpy.context.scene + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) + wall_obj = bpy.data.objects["Cube"] + scene.collection.objects.link(wall_obj) + tool.Ifc.link(wall, wall_obj) + + scene.cursor.location = (0, 0, 0) + bpy.ops.bim.generate_space() + space = bpy.data.objects["IfcSpace/Space"] + + spatial_props = tool.Spatial.get_spatial_props() + spatial_props.space_height = 6 + bpy.context.view_layer.objects.active = space + space.select_set(True) + wall_obj.select_set(False) + + bpy.ops.bim.apply_space_height_to_selection() + bpy.context.view_layer.update() + assert np.isclose(space.dimensions.z, 6, atol=0.1), f"Expected height 6, got {space.dimensions.z}" + + def test_cache_survives_second_generation(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + scene = bpy.context.scene + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) + wall_obj = bpy.data.objects["Cube"] + scene.collection.objects.link(wall_obj) + tool.Ifc.link(wall, wall_obj) + scene.cursor.location = (0, 0, 0) + + bpy.ops.bim.generate_space() + space1 = bpy.data.objects["IfcSpace/Space"] + height1 = space1.dimensions.z + + bpy.ops.bim.generate_space() + space2 = bpy.data.objects["IfcSpace/Space"] + height2 = space2.dimensions.z + + assert np.isclose(height1, height2, atol=0.1), f"Cache changed height: {height1} vs {height2}" + + def test_regenerate_after_wall_height_change(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + scene = bpy.context.scene + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) + wall_obj = bpy.data.objects["Cube"] + scene.collection.objects.link(wall_obj) + tool.Ifc.link(wall, wall_obj) + scene.cursor.location = (0, 0, 0) + + bpy.ops.bim.generate_space() + space = bpy.data.objects["IfcSpace/Space"] + original_height = space.dimensions.z + + wall_obj.dimensions.z = original_height + 2 + bpy.context.view_layer.update() + + bpy.context.view_layer.objects.active = space + space.select_set(True) + wall_obj.select_set(False) + + bpy.ops.bim.generate_space() + new_height = space.dimensions.z + assert new_height != original_height or new_height > 0