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.
This commit is contained in:
CyrilWaechter
2026-07-27 09:57:45 +02:00
parent 8deefe497c
commit 79ee88da5c
8 changed files with 389 additions and 19 deletions
+19
View File
@@ -59,6 +59,7 @@ from bonsai.bim.module.model.decorator import (
) )
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator 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__)) cwd = os.path.dirname(os.path.realpath(__file__))
global_subscription_owner = object() 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(): def active_object_callback():
refresh_ui_data() refresh_ui_data()
update_bim_tool_props() update_bim_tool_props()
update_spatial_tool_props()
tool.Geometry.sync_item_positions() 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(): def update_bim_tool_props():
"""Selection-driven BIM Tool sync: re-target user-intent enums """Selection-driven BIM Tool sync: re-target user-intent enums
(ifc_class, relating_type_id) AND refresh header values (ifc_class, relating_type_id) AND refresh header values
@@ -528,6 +545,7 @@ def _install_viewport_overlays() -> None:
ArrayPreviewDecorator.uninstall() ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall() ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers() uninstall_decorator_cache_handlers()
uninstall_geom_cache_handlers()
try: try:
if georeference_props.should_visualise: if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context) GeoreferenceDecorator.install(bpy.context)
@@ -570,6 +588,7 @@ def _install_viewport_overlays() -> None:
ArrayPreviewDecorator.install(bpy.context) ArrayPreviewDecorator.install(bpy.context)
finally: finally:
install_decorator_cache_handlers() install_decorator_cache_handlers()
install_geom_cache_handlers()
@persistent @persistent
@@ -178,6 +178,7 @@ classes = (
covering.RegenSelectedCoveringObject, covering.RegenSelectedCoveringObject,
space.ToggleSpaceVisibility, space.ToggleSpaceVisibility,
space.ToggleHideSpaces, space.ToggleHideSpaces,
space.ApplySpaceHeightToSelection,
mep.FitFlowSegments, mep.FitFlowSegments,
mep.RegenerateDistributionElement, mep.RegenerateDistributionElement,
prop.SnapMousePoint, prop.SnapMousePoint,
@@ -18,7 +18,9 @@
import bpy import bpy
import ifcopenshell.util.unit
import bonsai.core.geometry as core_geometry
import bonsai.core.spatial as core import bonsai.core.spatial as core
import bonsai.tool as tool import bonsai.tool as tool
@@ -115,3 +117,47 @@ class ToggleHideSpaces(bpy.types.Operator):
def execute(self, context): def execute(self, context):
core.toggle_hide_spaces(tool.Ifc, tool.Spatial) core.toggle_hide_spaces(tool.Ifc, tool.Spatial)
return {"FINISHED"} 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.")
@@ -24,6 +24,7 @@ from bpy.props import (
BoolProperty, BoolProperty,
CollectionProperty, CollectionProperty,
EnumProperty, EnumProperty,
FloatProperty,
IntProperty, IntProperty,
PointerProperty, PointerProperty,
StringProperty, StringProperty,
@@ -277,6 +278,17 @@ class BIMSpatialDecompositionProperties(PropertyGroup):
should_include_children: BoolProperty( should_include_children: BoolProperty(
name="Should Include Children", default=True, update=update_should_include_children 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: if TYPE_CHECKING:
is_locked: bool is_locked: bool
@@ -294,6 +306,8 @@ class BIMSpatialDecompositionProperties(PropertyGroup):
subelement_class: str subelement_class: str
default_container: int default_container: int
should_include_children: bool should_include_children: bool
space_height: float
force_space_height: bool
@property @property
def active_container(self) -> Union[BIMContainer, None]: def active_container(self) -> Union[BIMContainer, None]:
@@ -83,9 +83,14 @@ class SpatialToolUI:
@classmethod @classmethod
def draw_default_interface(cls, context): def draw_default_interface(cls, context):
spatial_props = tool.Spatial.get_spatial_props()
row = cls.layout.row(align=True) row = cls.layout.row(align=True)
row.prop(data=cls.model_props, property="rl3", text="RL") row.prop(data=cls.model_props, property="rl3", text="RL")
row = cls.layout.row(align=True) 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 op_name = lambda op: op.get_rna_type().name
if AuthoringData.data["active_class"] == "IfcWall" and context.selected_objects: if AuthoringData.data["active_class"] == "IfcWall" and context.selected_objects:
add_layout_hotkey( add_layout_hotkey(
+23 -1
View File
@@ -206,7 +206,7 @@ def generate_space(
else: else:
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor() 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 isinstance(space_polygon, str):
if space_polygon == "NO POLYGONS FOUND": if space_polygon == "NO POLYGONS FOUND":
@@ -220,6 +220,14 @@ def generate_space(
else: else:
assert space_polygon 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"): if element and element.is_a("IfcSpace"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True) spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
else: else:
@@ -248,11 +256,25 @@ def generate_spaces_from_walls(
z = spatial.get_active_obj_z() z = spatial.get_active_obj_z()
h = spatial.get_active_obj_height() 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() union = spatial.get_union_shape_from_selected_objects()
props = spatial.get_spatial_props()
for i, linear_ring in enumerate(union.interiors): for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) 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) name = "Space" + str(i)
obj = spatial.create_object(name) obj = spatial.create_object(name)
+154 -18
View File
@@ -19,6 +19,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import multiprocessing
from collections import defaultdict from collections import defaultdict
from collections.abc import Generator, Iterable from collections.abc import Generator, Iterable
from typing import TYPE_CHECKING, Any, Literal, Optional, Union from typing import TYPE_CHECKING, Any, Literal, Optional, Union
@@ -34,7 +35,9 @@ import ifcopenshell.util.classification
import ifcopenshell.util.element import ifcopenshell.util.element
import ifcopenshell.util.placement import ifcopenshell.util.placement
import ifcopenshell.util.representation import ifcopenshell.util.representation
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder import ifcopenshell.util.shape_builder
import ifcopenshell.util.space
import ifcopenshell.util.type import ifcopenshell.util.type
import ifcopenshell.util.unit import ifcopenshell.util.unit
import numpy as np import numpy as np
@@ -58,8 +61,52 @@ if TYPE_CHECKING:
BIMSpatialDecompositionProperties, 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): class Spatial(bonsai.core.tool.Spatial):
_geom_cache: dict = {}
@classmethod @classmethod
def get_spatial_props(cls) -> BIMSpatialDecompositionProperties: def get_spatial_props(cls) -> BIMSpatialDecompositionProperties:
return bpy.context.scene.BIMSpatialDecompositionProperties return bpy.context.scene.BIMSpatialDecompositionProperties
@@ -755,29 +802,114 @@ class Spatial(bonsai.core.tool.Spatial):
# HERE STARTS SPATIAL TOOL # 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 @classmethod
def is_bounding_class(cls, visible_element: ifcopenshell.entity_instance) -> bool: 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): if visible_element.is_a(ifc_class):
return True return True
return False return False
@classmethod @classmethod
def get_space_polygon_from_context_visible_objects( def get_boundary_lines_from_ifc_elements(
cls, x: float, y: float cls,
) -> Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]]: cut_z: float,
boundary_lines = cls.get_boundary_lines_from_context_visible_objects() ) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) """Generate boundary lines by bisecting IFC element geometry with a horizontal plane.
closed_polygons = shapely.polygonize(unioned_boundaries.geoms)
if not closed_polygons: Uses the class-level geometry cache (parallel iterator) instead of
return "NO POLYGONS FOUND" iterating Blender visible objects. Works without any Blender objects
space_polygon = None being loaded.
for polygon in closed_polygons.geoms:
if shapely.contains_xy(polygon, x, y): :param cut_z: Z elevation of the cutting plane in world coordinates.
space_polygon = shapely.force_3d(polygon) :return: (boundary_lines, bounding_elements)
if space_polygon is None: """
return "NO POLYGON FOR POINT" cache = cls.get_or_build_geom_cache()
return space_polygon 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 @classmethod
def debug_shape(cls, foo: shapely.Polygon) -> None: def debug_shape(cls, foo: shapely.Polygon) -> None:
@@ -810,7 +942,9 @@ class Spatial(bonsai.core.tool.Spatial):
bpy.context.view_layer.update() bpy.context.view_layer.update()
@classmethod @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() props = tool.Model.get_model_props()
calculation_rl = props.rl3 calculation_rl = props.rl3
container = tool.Root.get_default_container() 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_point = container_obj.matrix_world.translation.copy() + Vector((0, 0, calculation_rl))
cut_normal = Vector((0, 0, 1)) cut_normal = Vector((0, 0, 1))
boundary_lines = [] boundary_lines = []
bounding_elements = []
for obj in bpy.context.visible_objects: for obj in bpy.context.visible_objects:
visible_element = tool.Ifc.get_entity(obj) visible_element = tool.Ifc.get_entity(obj)
@@ -831,6 +966,7 @@ class Spatial(bonsai.core.tool.Spatial):
): ):
continue continue
bounding_elements.append(visible_element)
old_mesh = obj.data old_mesh = obj.data
assert isinstance(old_mesh, bpy.types.Mesh) assert isinstance(old_mesh, bpy.types.Mesh)
if visible_element.HasOpenings: if visible_element.HasOpenings:
@@ -870,7 +1006,7 @@ class Spatial(bonsai.core.tool.Spatial):
start, end = tool.Drawing.extend_line(start, end, 0.05) start, end = tool.Drawing.extend_line(start, end, 0.05)
boundary_lines.append(shapely.LineString([start, end])) boundary_lines.append(shapely.LineString([start, end]))
return boundary_lines return boundary_lines, bounding_elements
@classmethod @classmethod
def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh: def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh:
+127
View File
@@ -312,3 +312,130 @@ class TestGenerateSpace(NewFile):
bpy.ops.bim.generate_space() bpy.ops.bim.generate_space()
assert np.isclose(space.location.z, 5), f"Expected z=5, got {space.location.z}" 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