mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -178,6 +178,7 @@ classes = (
|
||||
covering.RegenSelectedCoveringObject,
|
||||
space.ToggleSpaceVisibility,
|
||||
space.ToggleHideSpaces,
|
||||
space.ApplySpaceHeightToSelection,
|
||||
mep.FitFlowSegments,
|
||||
mep.RegenerateDistributionElement,
|
||||
prop.SnapMousePoint,
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user