mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +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.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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user