mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-23 13:28:37 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 588557ae00 |
@@ -59,7 +59,6 @@ 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()
|
||||
@@ -122,25 +121,9 @@ 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
|
||||
@@ -545,7 +528,6 @@ 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)
|
||||
@@ -588,7 +570,6 @@ def _install_viewport_overlays() -> None:
|
||||
ArrayPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
install_geom_cache_handlers()
|
||||
|
||||
|
||||
@persistent
|
||||
|
||||
@@ -23,7 +23,6 @@ from . import operator, prop, ui
|
||||
classes = (
|
||||
operator.AddBoundary,
|
||||
operator.ColourByRelatedBuildingElement,
|
||||
operator.CopyBoundaryAttributeToSelection,
|
||||
operator.DecorateBoundaries,
|
||||
operator.DisableEditingBoundary,
|
||||
operator.DisableEditingBoundaryGeometry,
|
||||
|
||||
@@ -39,7 +39,6 @@ from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.bim.import_ifc as import_ifc
|
||||
import bonsai.core.attribute as core
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
@@ -423,32 +422,6 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CopyBoundaryAttributeToSelection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.copy_boundary_attribute_to_selection"
|
||||
bl_label = "Copy Boundary Attribute To Selection"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
name: bpy.props.StringProperty()
|
||||
|
||||
def _execute(self, context):
|
||||
obj = tool.Blender.get_active_object()
|
||||
assert obj
|
||||
bprops = tool.Boundary.get_object_boundary_props(obj)
|
||||
if self.name in EDITABLE_ATTRIBUTES:
|
||||
blender_prop = EDITABLE_ATTRIBUTES[self.name]
|
||||
blender_obj = getattr(bprops, blender_prop, None)
|
||||
value = tool.Ifc.get_entity(blender_obj) if blender_obj else None
|
||||
elif self.name == "PhysicalOrVirtualBoundary":
|
||||
value = bprops.physical_or_virtual
|
||||
elif self.name == "InternalOrExternalBoundary":
|
||||
value = bprops.internal_or_external
|
||||
else:
|
||||
return
|
||||
total = core.copy_attribute_to_selection(
|
||||
tool.Ifc, tool.Blender, tool.Root, tool.Spatial, name=self.name, value=value
|
||||
)
|
||||
self.report({"INFO"}, f"Attribute was successfully copied to {total} elements.")
|
||||
|
||||
|
||||
class UpdateBoundaryGeometry(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.update_boundary_geometry"
|
||||
bl_label = "Update Boundary Geometry"
|
||||
|
||||
@@ -77,14 +77,10 @@ class BIM_PT_Boundary(Panel):
|
||||
self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element")
|
||||
self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary")
|
||||
self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary")
|
||||
row = self.layout.row(align=True)
|
||||
row = self.layout.row()
|
||||
row.prop(self.bprops, "physical_or_virtual")
|
||||
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
|
||||
op.name = "PhysicalOrVirtualBoundary"
|
||||
row = self.layout.row(align=True)
|
||||
row = self.layout.row()
|
||||
row.prop(self.bprops, "internal_or_external")
|
||||
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
|
||||
op.name = "InternalOrExternalBoundary"
|
||||
else:
|
||||
row = self.layout.row()
|
||||
row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit")
|
||||
@@ -129,8 +125,6 @@ class BIM_PT_Boundary(Panel):
|
||||
if hasattr(boundary, ifc_attribute):
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(self.bprops, blender_property)
|
||||
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
|
||||
op.name = ifc_attribute
|
||||
|
||||
|
||||
class BIM_PT_SpaceBoundaries(Panel):
|
||||
|
||||
@@ -178,7 +178,6 @@ classes = (
|
||||
covering.RegenSelectedCoveringObject,
|
||||
space.ToggleSpaceVisibility,
|
||||
space.ToggleHideSpaces,
|
||||
space.ApplySpaceHeightToSelection,
|
||||
mep.FitFlowSegments,
|
||||
mep.RegenerateDistributionElement,
|
||||
prop.SnapMousePoint,
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
import bonsai.core.geometry as core_geometry
|
||||
import bonsai.core.spatial as core
|
||||
import bonsai.tool as tool
|
||||
|
||||
@@ -117,47 +115,3 @@ 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,7 +24,6 @@ from bpy.props import (
|
||||
BoolProperty,
|
||||
CollectionProperty,
|
||||
EnumProperty,
|
||||
FloatProperty,
|
||||
IntProperty,
|
||||
PointerProperty,
|
||||
StringProperty,
|
||||
@@ -278,17 +277,6 @@ 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
|
||||
@@ -306,8 +294,6 @@ 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,14 +83,9 @@ 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(
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -31,7 +31,7 @@ def copy_attribute_to_selection(
|
||||
root: type[tool.Root],
|
||||
spatial: type[tool.Spatial],
|
||||
name: str,
|
||||
value: Any,
|
||||
value: Union[str, None],
|
||||
) -> int:
|
||||
total_changed = 0
|
||||
has_edited_spatial_name = False
|
||||
|
||||
@@ -46,7 +46,7 @@ def add_instance_flooring_covering_from_cursor(
|
||||
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 = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
@@ -81,7 +81,7 @@ def add_instance_ceiling_covering_from_cursor(
|
||||
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
|
||||
ceiling_height = covering.get_z_from_ceiling_height()
|
||||
|
||||
space_polygon, _ = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
@@ -106,7 +106,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
|
||||
else:
|
||||
assert False, "Object has to be active and selected."
|
||||
|
||||
space_polygon, _ = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
@@ -206,7 +206,7 @@ def generate_space(
|
||||
else:
|
||||
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
|
||||
|
||||
space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
|
||||
if isinstance(space_polygon, str):
|
||||
if space_polygon == "NO POLYGONS FOUND":
|
||||
@@ -220,17 +220,7 @@ 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"):
|
||||
assert active_obj
|
||||
active_obj.location.z = z
|
||||
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
|
||||
else:
|
||||
if relating_type:
|
||||
@@ -258,25 +248,11 @@ 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,7 +19,6 @@
|
||||
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
|
||||
@@ -35,9 +34,7 @@ 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
|
||||
@@ -61,52 +58,8 @@ 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
|
||||
@@ -802,114 +755,29 @@ 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 ifcopenshell.util.space.BOUNDING_CLASSES:
|
||||
for ifc_class in ["IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate"]:
|
||||
if visible_element.is_a(ifc_class):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def debug_shape(cls, foo: shapely.Polygon) -> None:
|
||||
@@ -942,9 +810,7 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
@classmethod
|
||||
def get_boundary_lines_from_context_visible_objects(
|
||||
cls,
|
||||
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
|
||||
def get_boundary_lines_from_context_visible_objects(cls) -> list[shapely.LineString]:
|
||||
props = tool.Model.get_model_props()
|
||||
calculation_rl = props.rl3
|
||||
container = tool.Root.get_default_container()
|
||||
@@ -952,7 +818,6 @@ 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)
|
||||
@@ -966,7 +831,6 @@ 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:
|
||||
@@ -1006,7 +870,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, bounding_elements
|
||||
return boundary_lines
|
||||
|
||||
@classmethod
|
||||
def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh:
|
||||
|
||||
@@ -245,9 +245,16 @@ class Wall(bonsai.core.tool.Wall):
|
||||
@classmethod
|
||||
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
|
||||
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
|
||||
connecting a slab to this wall. Delegates to
|
||||
:func:`ifcopenshell.util.element.iter_top_connections`."""
|
||||
yield from ifcopenshell.util.element.iter_top_connections(wall)
|
||||
connecting a slab to this wall — the rel kind ``extend_walls_to_underside``
|
||||
creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
|
||||
side of the TOP rel."""
|
||||
for rel in getattr(wall, "ConnectedFrom", []) or ():
|
||||
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
|
||||
continue
|
||||
slab = rel.RelatingElement
|
||||
if slab is None:
|
||||
continue
|
||||
yield slab, rel
|
||||
|
||||
@classmethod
|
||||
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai
|
||||
import bonsai.core.covering as subject
|
||||
import bonsai.core.tool
|
||||
from test.core.bootstrap import Prophecy, ifc, root, spatial
|
||||
|
||||
# NOTE: The Prophecy mocking framework serialises call arguments as JSON,
|
||||
# which means shapely geometry objects cannot be passed through mocked
|
||||
# calls. We use the plain integer 42 as a serialisable stand-in for the
|
||||
# polygon return value; the test verifies the unpack behaviour (that the
|
||||
# polygon-like scalar 42 reaches set_covering_representation_from_polygon
|
||||
# instead of the tuple (42, []) which old code would have passed).
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def covering():
|
||||
prophet = Prophecy(bonsai.core.tool.Covering)
|
||||
yield prophet
|
||||
prophet.verify()
|
||||
|
||||
|
||||
class TestAddInstanceFlooringCoveringFromCursor:
|
||||
def test_run(self, ifc, root, spatial):
|
||||
root.get_default_container().should_be_called().will_return("container")
|
||||
spatial.get_active_obj().should_be_called().will_return(None)
|
||||
spatial.get_selected_objects().should_be_called().will_return([])
|
||||
spatial.get_relating_type_id().should_be_called().will_return(0)
|
||||
spatial.get_x_y_z_h_mat_from_cursor().should_be_called().will_return((0, 0, 0, 3, None))
|
||||
|
||||
spatial.get_space_polygon_from_context_visible_objects(0, 0).should_be_called().will_return((42, []))
|
||||
spatial.create_object("Covering").should_be_called().will_return("mock_obj")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation("mock_obj").should_be_called()
|
||||
spatial.translate_obj_to_z_location("mock_obj", 0).should_be_called()
|
||||
spatial.assign_type_to_obj("mock_obj").should_be_called()
|
||||
spatial.set_covering_representation_from_polygon("mock_obj", 42, polygon_is_si=True).should_be_called()
|
||||
|
||||
subject.add_instance_flooring_covering_from_cursor(ifc, root, spatial)
|
||||
|
||||
def test_raises_when_no_default_container(self, ifc, root, spatial):
|
||||
root.get_default_container().should_be_called().will_return(None)
|
||||
with pytest.raises(subject.NoDefaultContainer):
|
||||
subject.add_instance_flooring_covering_from_cursor(ifc, root, spatial)
|
||||
|
||||
|
||||
class TestAddInstanceCeilingCoveringFromCursor:
|
||||
def test_run(self, ifc, root, covering, spatial):
|
||||
root.get_default_container().should_be_called().will_return("container")
|
||||
spatial.get_active_obj().should_be_called().will_return(None)
|
||||
spatial.get_selected_objects().should_be_called().will_return([])
|
||||
spatial.get_relating_type_id().should_be_called().will_return(0)
|
||||
covering.get_z_from_ceiling_height().should_be_called().will_return(3.0)
|
||||
spatial.get_x_y_z_h_mat_from_cursor().should_be_called().will_return((0, 0, 0, 3, None))
|
||||
|
||||
spatial.get_space_polygon_from_context_visible_objects(0, 0).should_be_called().will_return((42, []))
|
||||
spatial.create_object("Covering").should_be_called().will_return("mock_obj")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation("mock_obj").should_be_called()
|
||||
spatial.translate_obj_to_z_location("mock_obj", 3.0).should_be_called()
|
||||
spatial.assign_type_to_obj("mock_obj").should_be_called()
|
||||
spatial.set_covering_representation_from_polygon("mock_obj", 42, polygon_is_si=True).should_be_called()
|
||||
|
||||
subject.add_instance_ceiling_covering_from_cursor(ifc, root, covering, spatial)
|
||||
|
||||
def test_raises_when_no_default_container(self, ifc, root, covering, spatial):
|
||||
root.get_default_container().should_be_called().will_return(None)
|
||||
with pytest.raises(subject.NoDefaultContainer):
|
||||
subject.add_instance_ceiling_covering_from_cursor(ifc, root, covering, spatial)
|
||||
|
||||
|
||||
class TestRegenSelectedCoveringObject:
|
||||
def test_run(self, root, spatial):
|
||||
root.get_default_container().should_be_called().will_return("container")
|
||||
spatial.get_active_obj().should_be_called().will_return("active")
|
||||
spatial.get_selected_objects().should_be_called().will_return(["active"])
|
||||
spatial.get_x_y_z_h_mat_from_obj("active").should_be_called().will_return((2, 3, 1, 3, None))
|
||||
|
||||
spatial.get_space_polygon_from_context_visible_objects(2, 3).should_be_called().will_return((42, []))
|
||||
spatial.set_covering_representation_from_polygon("active", 42, polygon_is_si=True).should_be_called()
|
||||
|
||||
subject.regen_selected_covering_object(root, spatial)
|
||||
|
||||
def test_raises_when_no_default_container(self, root, spatial):
|
||||
root.get_default_container().should_be_called().will_return(None)
|
||||
with pytest.raises(subject.NoDefaultContainer):
|
||||
subject.regen_selected_covering_object(root, spatial)
|
||||
|
||||
def test_raises_when_no_active_selected(self, root, spatial):
|
||||
root.get_default_container().should_be_called().will_return("container")
|
||||
spatial.get_active_obj().should_be_called().will_return(None)
|
||||
spatial.get_selected_objects().should_be_called().will_return([])
|
||||
with pytest.raises(AssertionError):
|
||||
subject.regen_selected_covering_object(root, spatial)
|
||||
@@ -24,14 +24,12 @@ import ifcopenshell.api.feature
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.util.representation
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.tool.spatial import Spatial as subject
|
||||
from bonsai.tool.spatial import _bump_geom_cache_token
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
|
||||
@@ -260,44 +258,17 @@ class TestSelectProducts(NewFile):
|
||||
assert obj in bpy.context.selected_objects
|
||||
|
||||
|
||||
class _BlockHelper:
|
||||
"""Shared helpers for creating IFC walls/slabs with solid-block representations."""
|
||||
|
||||
@staticmethod
|
||||
def create_wall(ifc, height=10.0):
|
||||
"""Create an IFC wall with a 10x10x{height} block representation from z=0."""
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
|
||||
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
|
||||
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 10.0, 10.0)
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-5.0, -5.0, 0.0]))
|
||||
extrusion = ifc.createIfcExtrudedAreaSolid(
|
||||
profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height
|
||||
)
|
||||
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
|
||||
wall.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
|
||||
return wall, extrusion
|
||||
|
||||
@staticmethod
|
||||
def create_slab(ifc, z=4.0):
|
||||
"""Create an IfcSlab with a 12x12x1.0 block representation at bottom_z={z}."""
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
|
||||
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
|
||||
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
|
||||
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 12.0, 12.0)
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-6.0, -6.0, z]))
|
||||
extrusion = ifc.createIfcExtrudedAreaSolid(profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), 1.0)
|
||||
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
|
||||
slab.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
|
||||
|
||||
|
||||
class TestGenerateSpace(NewFile):
|
||||
def test_generate_space_at_cursor(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
# The wall block spans z=0..10, bisects to a 10x10 polygon at cut_z.
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.scene.cursor.location = (0, 0, 0)
|
||||
scene = bpy.context.scene
|
||||
product = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
|
||||
obj = bpy.data.objects["Cube"]
|
||||
scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(product, obj)
|
||||
scene.cursor.location = (0, 0, 0)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
space = bpy.data.objects["IfcSpace/Space"]
|
||||
@@ -321,8 +292,13 @@ class TestGenerateSpace(NewFile):
|
||||
def test_regenerate_space_preserves_z_location(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.scene.cursor.location = (0, 0, 0)
|
||||
scene = bpy.context.scene
|
||||
product = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
|
||||
obj = bpy.data.objects["Cube"]
|
||||
scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(product, obj)
|
||||
scene.cursor.location = (0, 0, 0)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
space = bpy.data.objects["IfcSpace/Space"]
|
||||
@@ -331,158 +307,8 @@ class TestGenerateSpace(NewFile):
|
||||
|
||||
bpy.context.view_layer.objects.active = space
|
||||
space.select_set(True)
|
||||
obj.select_set(False)
|
||||
|
||||
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()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
_BlockHelper.create_slab(ifc, z=4.0)
|
||||
bpy.context.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()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.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()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.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()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.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)
|
||||
|
||||
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()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.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()
|
||||
wall, extrusion = _BlockHelper.create_wall(ifc, height=10.0)
|
||||
bpy.context.scene.cursor.location = (0, 0, 0)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
space = bpy.data.objects["IfcSpace/Space"]
|
||||
original_height = space.dimensions.z
|
||||
|
||||
# Modify the IFC representation to change the wall height.
|
||||
extrusion.Depth = 15.0
|
||||
_bump_geom_cache_token()
|
||||
|
||||
bpy.context.view_layer.objects.active = space
|
||||
space.select_set(True)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
new_height = space.dimensions.z
|
||||
assert new_height != original_height or new_height > 0
|
||||
|
||||
def test_regenerate_space_from_centered_cube_representation(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
scene = bpy.context.scene
|
||||
scene.cursor.location = (0, 0, 0)
|
||||
|
||||
# Create a space with a unit cube PolygonalFaceSet centered at local origin.
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
|
||||
points = ifc.createIfcCartesianPointList3D(
|
||||
[
|
||||
(-0.5, -0.5, -0.5),
|
||||
(-0.5, -0.5, 0.5),
|
||||
(-0.5, 0.5, -0.5),
|
||||
(-0.5, 0.5, 0.5),
|
||||
(0.5, -0.5, -0.5),
|
||||
(0.5, -0.5, 0.5),
|
||||
(0.5, 0.5, -0.5),
|
||||
(0.5, 0.5, 0.5),
|
||||
]
|
||||
)
|
||||
faces = [
|
||||
ifc.createIfcIndexedPolygonalFace([1, 2, 4, 3]),
|
||||
ifc.createIfcIndexedPolygonalFace([3, 4, 8, 7]),
|
||||
ifc.createIfcIndexedPolygonalFace([7, 8, 6, 5]),
|
||||
ifc.createIfcIndexedPolygonalFace([5, 6, 2, 1]),
|
||||
ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]),
|
||||
ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]),
|
||||
]
|
||||
face_set = ifc.createIfcPolygonalFaceSet(points, closed=True, faces=faces)
|
||||
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "Tessellation", [face_set])
|
||||
|
||||
space_element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace")
|
||||
space_element.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 5))
|
||||
obj = bpy.data.objects["Cube"]
|
||||
scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(space_element, obj)
|
||||
bpy.context.view_layer.update()
|
||||
obj.name = "MySpace"
|
||||
|
||||
# Check the cube's world bottom Z before regeneration.
|
||||
bottom_z = (obj.matrix_world @ Vector(obj.bound_box[0])).z
|
||||
assert np.isclose(bottom_z, 4.5), f"Expected bottom_z=4.5, got {bottom_z}"
|
||||
|
||||
# Regenerate the space.
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
mesh = obj.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
verts = [v.co.z for v in mesh.vertices]
|
||||
min_z = min(verts)
|
||||
max_z = max(verts)
|
||||
assert min_z >= 0, f"Expected extrusion to start at local z>=0, got min_z={min_z}"
|
||||
assert max_z > 0, f"Expected extrusion to have positive height, got max_z={max_z}"
|
||||
assert np.isclose(obj.location.z, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}"
|
||||
|
||||
@@ -51,10 +51,6 @@ def create(
|
||||
|
||||
If geometric representations are created, the alignment stationing referent is also created using the start_station value. IfcReferent.ObjectPlacement
|
||||
is required for linear positiion elements and IfcLinearPlacement is defined relative to alignment curve geometry.
|
||||
This referent's Name follows the same "<alignment name> <station>" convention update_key_point_referents() uses
|
||||
for its own key-point referents (e.g. "MyAlignment 49+00.00"), so that every referent nested under an alignment
|
||||
is identifiable by name alone, without needing to inspect its Pset_Stationing or placement to know which
|
||||
alignment it belongs to.
|
||||
|
||||
:param file:
|
||||
:param name: name assigned to IfcAlignment.Name
|
||||
@@ -90,7 +86,7 @@ def create(
|
||||
if include_geometry:
|
||||
_create_geometric_representation(file, alignment)
|
||||
|
||||
referent_name = f"{name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
|
||||
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
|
||||
|
||||
for layout in alignment_layouts:
|
||||
|
||||
@@ -128,11 +128,6 @@ def create_as_polyline(
|
||||
|
||||
The IfcAlignment is aggreated to IfcProject
|
||||
|
||||
The stationing referent created from start_station has Name "<alignment name> <station>"
|
||||
(e.g. "MyAlignment 49+00.00"), the same convention update_key_point_referents() and
|
||||
create() use for their own referents, so every referent nested under an alignment is
|
||||
identifiable by name alone.
|
||||
|
||||
:param file:
|
||||
:param name: name assigned to IfcAlignment.Name
|
||||
:param points: sequence of points defining the polyline
|
||||
@@ -147,8 +142,8 @@ def create_as_polyline(
|
||||
_create_polyline_representation(file, alignment, points)
|
||||
|
||||
# define stationing
|
||||
referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
|
||||
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station)
|
||||
|
||||
# IFC 4.1.4.1.1 Alignment Aggregation To Project
|
||||
project = file.by_type("IfcProject")[0]
|
||||
|
||||
@@ -115,19 +115,11 @@ def update_key_point_referents(
|
||||
get_stationing_nest) -- key-point referents never belong in either of those.
|
||||
|
||||
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
|
||||
:param rel_nests: an existing IfcRelNests to (re)populate; its RelatingObject must be an
|
||||
IfcAlignment (TypeError is raised otherwise), but need not be the IfcAlignment that
|
||||
directly nests `layout` -- passing an ancestor's own IfcRelNests is supported
|
||||
specifically so that a vertical/cant layout living under a child IfcAlignment (per CT
|
||||
4.1.4.4.1.2, once a second vertical layout is added) can still have its key-point
|
||||
referents named after and nested to the top-level parent alignment, matching how the
|
||||
alignment's horizontal key points are named, rather than a generic "Child of X" name.
|
||||
When `rel_nests` is given, `rel_nests.RelatingObject` -- not `layout`'s own direct
|
||||
parent -- is used for both the created referents' Name and the returned IfcRelNests. If
|
||||
omitted, a new IfcRelNests is always created and related to `layout`'s own direct
|
||||
parent alignment -- there is no implicit search for or reuse of a previously created
|
||||
nest. Callers who want to regenerate into an existing nest must pass it back in
|
||||
explicitly via `rel_nests`.
|
||||
:param rel_nests: an existing IfcRelNests to (re)populate; its RelatingObject must be the
|
||||
IfcAlignment that nests `layout` (TypeError is raised otherwise). If omitted, a new
|
||||
IfcRelNests is always created and related to that IfcAlignment -- there is no implicit
|
||||
search for or reuse of a previously created nest. Callers who want to regenerate into an
|
||||
existing nest must pass it back in explicitly via `rel_nests`.
|
||||
:param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their
|
||||
Pset_Stationing) before regenerating. If False (default), new referents are appended to
|
||||
whatever already exists -- no deduplication.
|
||||
@@ -163,25 +155,17 @@ def update_key_point_referents(
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
|
||||
)
|
||||
|
||||
layout_alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
if layout_alignment is None:
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
if alignment is None:
|
||||
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
|
||||
|
||||
# `alignment` is used below for referent naming (and as the fallback-placement basis) --
|
||||
# it defaults to layout's own direct parent, but an explicitly passed rel_nests overrides
|
||||
# it with rel_nests.RelatingObject instead (see the rel_nests docstring above). Station
|
||||
# computation always uses layout_alignment, unaffected by this -- get_alignment_start_station
|
||||
# already walks up to the true top-level alignment's own stationing referent regardless of
|
||||
# which (possibly child) alignment it's given.
|
||||
if rel_nests is not None:
|
||||
if not rel_nests.RelatingObject.is_a("IfcAlignment"):
|
||||
raise TypeError(
|
||||
f"Expected rel_nests.RelatingObject to be IfcAlignment, instead received "
|
||||
f"{rel_nests.RelatingObject.is_a()}"
|
||||
)
|
||||
alignment = rel_nests.RelatingObject
|
||||
else:
|
||||
alignment = layout_alignment
|
||||
rel_nests = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()
|
||||
)
|
||||
@@ -201,7 +185,7 @@ def update_key_point_referents(
|
||||
)
|
||||
return rel_nests
|
||||
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, layout_alignment)
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import ifcopenshell.util.sequence
|
||||
|
||||
def create_baseline(
|
||||
file: ifcopenshell.file, work_schedule: ifcopenshell.entity_instance, name: Optional[str] = None
|
||||
) -> ifcopenshell.entity_instance:
|
||||
) -> None:
|
||||
"""Creates a baseline for your Work Schedule
|
||||
|
||||
Using a IfcWorkSchdule having PredefinedType=PLANNED,
|
||||
@@ -42,7 +42,7 @@ def create_baseline(
|
||||
* Same Construction Resources
|
||||
* Same Resource Relationships
|
||||
|
||||
:param work_schedule: The planned work schedule to baseline
|
||||
:param work_schedule: The planned work_schedule to baseline
|
||||
:param name: baseline work schedule name
|
||||
:return: The baseline work_schedule
|
||||
|
||||
@@ -51,7 +51,7 @@ def create_baseline(
|
||||
.. code:: python
|
||||
|
||||
# We have a Work Schedule
|
||||
planned_work_schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Planned Construction Schedule")
|
||||
planned_work_schedule = WorkSchedule(name="Design new feature",predefinedType="PLANNED", deadline="2023-03-01")
|
||||
|
||||
# And now we have a baseline for our Work Schedule
|
||||
baseline_work_schedule = ifcopenshell.api.sequence.create_baseline(file, work_schedule=planned_work_schedule, name="Baseline 1")
|
||||
@@ -64,23 +64,24 @@ def create_baseline(
|
||||
class Usecase:
|
||||
file: ifcopenshell.file
|
||||
|
||||
def execute(
|
||||
self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]
|
||||
) -> ifcopenshell.entity_instance:
|
||||
if work_schedule.PredefinedType != "PLANNED":
|
||||
raise ValueError("Only a PLANNED work schedule can be baselined.")
|
||||
def execute(self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]) -> None:
|
||||
# create work schedule
|
||||
if not work_schedule.PredefinedType == "PLANNED":
|
||||
return
|
||||
baseline_work_schedule = ifcopenshell.api.sequence.add_work_schedule(
|
||||
self.file, name=name or work_schedule.Name, predefined_type="BASELINE"
|
||||
self.file, name=work_schedule.Name, predefined_type="BASELINE"
|
||||
)
|
||||
baseline_work_schedule.Name = name
|
||||
self.create_baseline_reference(work_schedule, baseline_work_schedule)
|
||||
for summary_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
|
||||
current, duplicate = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task)
|
||||
res = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task)
|
||||
assert isinstance(res, list)
|
||||
current, duplicate = res
|
||||
ifcopenshell.api.control.assign_control(
|
||||
self.file, relating_control=baseline_work_schedule, related_objects=[duplicate[0]]
|
||||
)
|
||||
for i, task in enumerate(current):
|
||||
self.create_baseline_reference(task, duplicate[i])
|
||||
return baseline_work_schedule
|
||||
|
||||
def create_baseline_reference(
|
||||
self, relating_object: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance
|
||||
|
||||
@@ -69,21 +69,21 @@ def station_as_string(file: ifcopenshell.file, sta: float):
|
||||
Returns a stringized version of a station. Example 100.0 is 1+00.00 as a stationing string.
|
||||
If the project units are SI-based, the string is in the format xxx+yyy.zzz
|
||||
If the project units are Emperial-based, the string is in the format xx+yy.zz
|
||||
|
||||
:param station: the station to be stringized
|
||||
:return: stringized station
|
||||
"""
|
||||
|
||||
unit_type = ifcopenshell.util.unit.get_project_unit(file, "LENGTHUNIT")
|
||||
project_unit_to_metres = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||
if unit_type.is_a("IfcConversionBasedUnit"):
|
||||
# xx+yy.zz display is inherently foot-based, regardless of which foot variant
|
||||
# (international vs. US survey, etc.) the project's own unit actually is.
|
||||
station = sta * project_unit_to_metres / 0.3048
|
||||
station = ifcopenshell.util.unit.convert(
|
||||
sta, from_unit=unit_type.Name, from_prefix=None, to_unit="foot", to_prefix=None
|
||||
)
|
||||
plus_seperator = 2
|
||||
precision = 2
|
||||
else:
|
||||
station = sta * project_unit_to_metres
|
||||
station = ifcopenshell.util.unit.convert(
|
||||
sta, from_unit=unit_type.Name, from_prefix=unit_type.Prefix, to_unit="meter", to_prefix=None
|
||||
)
|
||||
plus_seperator = 3
|
||||
precision = 3
|
||||
|
||||
|
||||
@@ -2007,24 +2007,3 @@ def get_material_profiles(element: ifcopenshell.entity_instance) -> list[Priorit
|
||||
)
|
||||
for material_profile in material.MaterialProfiles
|
||||
]
|
||||
|
||||
|
||||
def iter_top_connections(
|
||||
element: ifcopenshell.entity_instance,
|
||||
) -> Generator[tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance], None, None]:
|
||||
"""Yield ``(connected_element, rel)`` tuples for every
|
||||
``IfcRelConnectsElements`` with ``Description == "TOP"`` connecting
|
||||
to this element.
|
||||
|
||||
Walks ``element.ConnectedFrom`` because the connecting element (e.g. a
|
||||
slab) is the relating side of the TOP relationship.
|
||||
|
||||
:param element: The IFC element (typically a wall).
|
||||
:return: Generator of ``(connected_element, rel)`` tuples.
|
||||
"""
|
||||
for rel in getattr(element, "ConnectedFrom", []) or ():
|
||||
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
|
||||
continue
|
||||
connected = rel.RelatingElement
|
||||
if connected is not None:
|
||||
yield connected, rel
|
||||
|
||||
@@ -752,81 +752,3 @@ def get_total_edge_length(geometry: W.triangulation) -> float:
|
||||
vertices = get_vertices(geometry)
|
||||
vertices = vertices[get_edges(geometry)]
|
||||
return np.linalg.norm(vertices[:, 1] - vertices[:, 0], axis=1).sum().item()
|
||||
|
||||
|
||||
def _extend_line(start: np.ndarray, end: np.ndarray, distance: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Extend a line segment by a fixed distance on both ends.
|
||||
|
||||
:param start: (x, y) or (x, y, z) array.
|
||||
:param end: (x, y) or (x, y, z) array.
|
||||
:param distance: Distance to extend on each end.
|
||||
:return: (new_start, new_end) arrays.
|
||||
"""
|
||||
direction = end - start
|
||||
norm = np.linalg.norm(direction)
|
||||
if norm == 0:
|
||||
return start, end
|
||||
offset = distance * (direction / norm)
|
||||
return start - offset, end + offset
|
||||
|
||||
|
||||
def bisect_mesh_plane_vf(
|
||||
verts: npt.NDArray[np.float64],
|
||||
faces: npt.NDArray[np.int32],
|
||||
plane_z: float,
|
||||
*,
|
||||
precision: int = 3,
|
||||
extend: float = 0.0,
|
||||
) -> list:
|
||||
"""Intersect a triangulated mesh with a horizontal Z plane.
|
||||
|
||||
All faces are processed at once via numpy broadcasting for performance.
|
||||
|
||||
:param verts: (n, 3) array of vertices in world coordinates.
|
||||
:param faces: (m, 3) array of triangle vertex indices.
|
||||
:param plane_z: Z elevation of the horizontal cutting plane.
|
||||
:param precision: Decimal places to round intersection point coordinates to.
|
||||
:param extend: Distance to extend each segment on both ends, to ensure
|
||||
overlap with neighbouring segments for polygon closure.
|
||||
:return: List of (start_xy, end_xy) tuples where each coordinate is (x, y).
|
||||
"""
|
||||
if len(faces) == 0:
|
||||
return []
|
||||
v0 = verts[faces[:, 0]]
|
||||
v1 = verts[faces[:, 1]]
|
||||
v2 = verts[faces[:, 2]]
|
||||
d0 = v0[:, 2] - plane_z
|
||||
d1 = v1[:, 2] - plane_z
|
||||
d2 = v2[:, 2] - plane_z
|
||||
straddle = ~((np.minimum(np.minimum(d0, d1), d2) > 0) | (np.maximum(np.maximum(d0, d1), d2) < 0))
|
||||
if not np.any(straddle):
|
||||
return []
|
||||
idx = np.where(straddle)[0]
|
||||
d0s, d1s, d2s = d0[idx], d1[idx], d2[idx]
|
||||
v0s, v1s, v2s = v0[idx], v1[idx], v2[idx]
|
||||
|
||||
def _edge_intersections(va, vb, da, db):
|
||||
mask = da * db < 0
|
||||
diff = da - db
|
||||
diff = np.where(diff == 0, 1.0, diff)
|
||||
t = np.where(mask, da / diff, 0.0)
|
||||
pts = va + t[:, np.newaxis] * (vb - va)
|
||||
return pts, mask
|
||||
|
||||
p01, m01 = _edge_intersections(v0s, v1s, d0s, d1s)
|
||||
p12, m12 = _edge_intersections(v1s, v2s, d1s, d2s)
|
||||
p20, m20 = _edge_intersections(v2s, v0s, d2s, d0s)
|
||||
|
||||
segments = []
|
||||
for i in range(len(idx)):
|
||||
pts_xy = []
|
||||
for pt, mask in ((p01[i], m01[i]), (p12[i], m12[i]), (p20[i], m20[i])):
|
||||
if mask:
|
||||
pts_xy.append((round(float(pt[0]), precision), round(float(pt[1]), precision)))
|
||||
if len(pts_xy) == 2 and pts_xy[0] != pts_xy[1]:
|
||||
if extend > 0:
|
||||
s, e = _extend_line(np.array(pts_xy[0]), np.array(pts_xy[1]), extend)
|
||||
segments.append((s.tolist(), e.tolist()))
|
||||
else:
|
||||
segments.append(pts_xy)
|
||||
return segments
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Blender-independent utilities for space geometry generation.
|
||||
|
||||
These functions operate on IFC geometry data (vertices, faces, element
|
||||
relationships) without requiring any Blender objects to be loaded. They are
|
||||
used by Bonsai's space generation pipeline but can also be used standalone
|
||||
for IFC analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.shape
|
||||
import shapely
|
||||
|
||||
BOUNDING_CLASSES = ("IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate")
|
||||
HEIGHT_DETECTION_CLASSES = ("IfcSlab", "IfcRoof")
|
||||
|
||||
|
||||
def get_boundary_lines(
|
||||
ifc_file: ifcopenshell.file,
|
||||
shapes: dict,
|
||||
cut_z: float,
|
||||
bounding_classes: tuple = BOUNDING_CLASSES,
|
||||
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
|
||||
"""Generate boundary lines by bisecting IFC element geometry with a horizontal plane.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param shapes: Dict of element shapes keyed by element id, as produced by
|
||||
a geometry cache. Each entry must have ``verts`` (n,3 ndarray),
|
||||
``faces`` (m,3 ndarray), ``bottom_z`` (float), ``top_z`` (float).
|
||||
:param cut_z: Z elevation of the cutting plane in world coordinates.
|
||||
:param bounding_classes: IFC classes to treat as space-bounding elements.
|
||||
:return: ``(boundary_lines, bounding_elements)`` where boundary_lines is a
|
||||
list of shapely LineString segments and bounding_elements is a list of
|
||||
IFC entity instances that intersect the cutting plane.
|
||||
"""
|
||||
boundary_lines: list[shapely.LineString] = []
|
||||
bounding_elements: list[ifcopenshell.entity_instance] = []
|
||||
|
||||
for element_id, shape_data in shapes.items():
|
||||
element = ifc_file.by_id(element_id)
|
||||
if not any(element.is_a(cls) for cls in bounding_classes):
|
||||
continue
|
||||
if cut_z <= shape_data["bottom_z"] or cut_z >= shape_data["top_z"]:
|
||||
continue
|
||||
bounding_elements.append(element)
|
||||
segments = ifcopenshell.util.shape.bisect_mesh_plane_vf(
|
||||
shape_data["verts"], shape_data["faces"], cut_z, precision=3, extend=0.05
|
||||
)
|
||||
for start, end in segments:
|
||||
boundary_lines.append(shapely.LineString([start, end]))
|
||||
|
||||
return boundary_lines, bounding_elements
|
||||
|
||||
|
||||
def get_space_polygon(
|
||||
boundary_lines: list[shapely.LineString],
|
||||
x: float,
|
||||
y: float,
|
||||
) -> tuple[Union[shapely.Polygon, str], list]:
|
||||
"""Assemble boundary lines into closed polygons and find the one containing (x, y).
|
||||
|
||||
:param boundary_lines: List of shapely LineString segments forming a planar graph.
|
||||
:param x: X coordinate of the point to test.
|
||||
:param y: Y coordinate of the point to test.
|
||||
:return: ``(polygon, [])`` on success, or ``("NO POLYGONS FOUND", [])`` /
|
||||
``("NO POLYGON FOR POINT", [])`` on failure. The second element is
|
||||
reserved for bounding elements (returned by the caller from
|
||||
:func:`get_boundary_lines`).
|
||||
"""
|
||||
unioned = shapely.union_all(shapely.GeometryCollection(boundary_lines))
|
||||
closed_polygons = shapely.polygonize(unioned.geoms)
|
||||
if not closed_polygons:
|
||||
return "NO POLYGONS FOUND", []
|
||||
for polygon in closed_polygons.geoms:
|
||||
if shapely.contains_xy(polygon, x, y):
|
||||
return shapely.force_3d(polygon), []
|
||||
return "NO POLYGON FOR POINT", []
|
||||
|
||||
|
||||
def get_auto_space_height(
|
||||
ifc_file: ifcopenshell.file,
|
||||
shapes: dict,
|
||||
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.
|
||||
|
||||
Detection priority:
|
||||
1. ``IfcRelConnectsElements`` (TOP) connections on bounding walls
|
||||
2. ``IfcSlab`` / ``IfcRoof`` elements above with XY overlap to the space polygon
|
||||
3. Minimum wall top Z of bounding walls
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param shapes: Dict of element shapes keyed by element id (see :func:`get_boundary_lines`).
|
||||
: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 meters, or ``None`` if nothing found.
|
||||
"""
|
||||
height = get_height_from_top_connections(ifc_file, shapes, bounding_walls, base_z, space_polygon)
|
||||
if height is not None and height > 0:
|
||||
return height
|
||||
|
||||
height = get_height_from_elements_above(ifc_file, shapes, space_polygon, base_z)
|
||||
if height is not None and height > 0:
|
||||
return height
|
||||
|
||||
height = get_height_from_wall_tops(shapes, bounding_walls, base_z)
|
||||
if height is not None and height > 0:
|
||||
return height
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_height_from_top_connections(
|
||||
ifc_file: ifcopenshell.file,
|
||||
shapes: dict,
|
||||
bounding_walls: list[ifcopenshell.entity_instance],
|
||||
base_z: float,
|
||||
space_polygon: shapely.Polygon,
|
||||
) -> Optional[float]:
|
||||
"""Find the lowest bottom face of elements connected to bounding walls via IfcRelConnectsElements(TOP).
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param shapes: Dict of element shapes keyed by element id.
|
||||
:param bounding_walls: List of IFC wall elements.
|
||||
:param base_z: The space's base Z in world coordinates.
|
||||
:param space_polygon: The space footprint polygon in world XY.
|
||||
:return: Height in meters, or ``None``.
|
||||
"""
|
||||
lowest_min_z: Optional[float] = None
|
||||
for wall_element in bounding_walls:
|
||||
for connected_element, _rel in ifcopenshell.util.element.iter_top_connections(wall_element):
|
||||
if not (connected_element.is_a("IfcSlab") or connected_element.is_a("IfcRoof")):
|
||||
continue
|
||||
shape_data = shapes.get(connected_element.id())
|
||||
if not shape_data:
|
||||
continue
|
||||
min_z = shape_data["bottom_z"]
|
||||
if min_z <= base_z:
|
||||
continue
|
||||
verts = shape_data["verts"]
|
||||
element_box = shapely.box(
|
||||
float(verts[:, 0].min()),
|
||||
float(verts[:, 1].min()),
|
||||
float(verts[:, 0].max()),
|
||||
float(verts[:, 1].max()),
|
||||
)
|
||||
if not element_box.intersects(space_polygon):
|
||||
continue
|
||||
if lowest_min_z is None or min_z < lowest_min_z:
|
||||
lowest_min_z = min_z
|
||||
if lowest_min_z is not None:
|
||||
return lowest_min_z - base_z
|
||||
return None
|
||||
|
||||
|
||||
def get_height_from_elements_above(
|
||||
ifc_file: ifcopenshell.file,
|
||||
shapes: dict,
|
||||
space_polygon: shapely.Polygon,
|
||||
base_z: float,
|
||||
height_classes: tuple = HEIGHT_DETECTION_CLASSES,
|
||||
) -> Optional[float]:
|
||||
"""Find the lowest IfcSlab / IfcRoof above whose XY bbox overlaps the space polygon.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param shapes: Dict of element shapes keyed by element id.
|
||||
:param space_polygon: The space footprint polygon in world XY.
|
||||
:param base_z: The space's base Z in world coordinates.
|
||||
:param height_classes: IFC classes to consider as ceiling elements.
|
||||
:return: Height in meters, or ``None``.
|
||||
"""
|
||||
lowest_min_z: Optional[float] = None
|
||||
for ifc_class in height_classes:
|
||||
for element in ifc_file.by_type(ifc_class):
|
||||
shape_data = shapes.get(element.id())
|
||||
if not shape_data:
|
||||
continue
|
||||
min_z = shape_data["bottom_z"]
|
||||
if min_z <= base_z:
|
||||
continue
|
||||
verts = shape_data["verts"]
|
||||
element_box = shapely.box(
|
||||
float(verts[:, 0].min()),
|
||||
float(verts[:, 1].min()),
|
||||
float(verts[:, 0].max()),
|
||||
float(verts[:, 1].max()),
|
||||
)
|
||||
if not element_box.intersects(space_polygon):
|
||||
continue
|
||||
if lowest_min_z is None or min_z < lowest_min_z:
|
||||
lowest_min_z = min_z
|
||||
if lowest_min_z is not None:
|
||||
return lowest_min_z - base_z
|
||||
return None
|
||||
|
||||
|
||||
def get_height_from_wall_tops(
|
||||
shapes: dict,
|
||||
bounding_walls: list[ifcopenshell.entity_instance],
|
||||
base_z: float,
|
||||
) -> Optional[float]:
|
||||
"""Find the minimum wall top Z among bounding walls.
|
||||
|
||||
:param shapes: Dict of element shapes keyed by element id.
|
||||
:param bounding_walls: List of IFC wall elements.
|
||||
:param base_z: The space's base Z in world coordinates.
|
||||
:return: Height in meters, or ``None``.
|
||||
"""
|
||||
lowest_top_z: Optional[float] = None
|
||||
for wall_element in bounding_walls:
|
||||
shape_data = shapes.get(wall_element.id())
|
||||
if not shape_data:
|
||||
continue
|
||||
max_z = shape_data["top_z"]
|
||||
if max_z <= base_z:
|
||||
continue
|
||||
if lowest_top_z is None or max_z < lowest_top_z:
|
||||
lowest_top_z = max_z
|
||||
if lowest_top_z is not None:
|
||||
return lowest_top_z - base_z
|
||||
return None
|
||||
@@ -962,8 +962,7 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
|
||||
)
|
||||
|
||||
unit_assignment = get_unit_assignment(file_patched)
|
||||
# UnitType not available on IfcMonetaryUnit
|
||||
unit_assignment.Units = [new_length, *(u for u in unit_assignment.Units if getattr(u, 'UnitType', None) != new_length.UnitType)]
|
||||
unit_assignment.Units = [new_length, *(u for u in unit_assignment.Units if u.UnitType != new_length.UnitType)]
|
||||
if not file_patched.get_total_inverses(old_length):
|
||||
ifcopenshell.util.element.remove_deep2(file_patched, old_length)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_add_stationing_to_alignment():
|
||||
referent = stationing_nest.RelatedObjects[0]
|
||||
|
||||
assert referent.PredefinedType == "STATION"
|
||||
assert referent.Name == "TestAlignment 2+000.000"
|
||||
assert referent.Name == "2+000.000"
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
@@ -22,39 +22,6 @@ import pytest
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def test_create_stationing_referent_name_includes_alignment_name():
|
||||
"""create() creates an initial stationing IfcReferent from start_station
|
||||
(see add_stationing_referent()). Its Name must include the alignment's
|
||||
own name, the same "<alignment name> <station>" convention
|
||||
update_key_point_referents() uses for its own referents -- otherwise
|
||||
this referent is indistinguishable by name alone from the same-named
|
||||
referent of any OTHER alignment in the same file, unlike every other
|
||||
referent in the model."""
|
||||
file = ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=4900.0)
|
||||
|
||||
referents = [
|
||||
r
|
||||
for r in ifcopenshell.util.element.get_components(alignment)
|
||||
if r.is_a("IfcReferent") and ifcopenshell.util.element.get_pset(r, name="Pset_Stationing", prop="Station") == 4900.0
|
||||
]
|
||||
assert len(referents) == 1
|
||||
assert referents[0].Name == "TestAlignment 49+00.00"
|
||||
|
||||
try:
|
||||
ifcopenshell.file(schema="IFC4")
|
||||
|
||||
@@ -21,7 +21,6 @@ import pytest
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
try:
|
||||
ifcopenshell.file(schema="IFC4X3")
|
||||
@@ -50,16 +49,7 @@ def test_create_as_polyline():
|
||||
file.createIfcCartesianPoint((-585.0, 3275.2, 56.2)),
|
||||
]
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create_as_polyline(file, "A1", points, start_station=100.0)
|
||||
alignment = ifcopenshell.api.alignment.create_as_polyline(file, "A1", points)
|
||||
curve = ifcopenshell.api.alignment.get_curve(alignment)
|
||||
assert curve.is_a("IfcPolyline")
|
||||
assert len(curve.Points) == 10
|
||||
|
||||
# stationing referent's Name must include the alignment's own name, the
|
||||
# same "<alignment name> <station>" convention create() and
|
||||
# update_key_point_referents() use -- previously this reassigned the
|
||||
# local `name` variable (shadowing the "A1" parameter) to just the bare
|
||||
# station string, losing the alignment name entirely.
|
||||
referents = [r for r in ifcopenshell.util.element.get_components(alignment) if r.is_a("IfcReferent")]
|
||||
assert len(referents) == 1
|
||||
assert referents[0].Name == "A1 0+100.000"
|
||||
|
||||
@@ -373,41 +373,6 @@ def test_start_station_composes_for_child_alignment():
|
||||
assert stations == pytest.approx([100.0, 600.0, 900.0])
|
||||
|
||||
|
||||
def test_rel_nests_from_ancestor_used_for_naming_and_nesting():
|
||||
"""A vertical layout living under a child alignment (once a second vertical layout is
|
||||
added, per CT 4.1.4.4.1.2) can still have its key-point referents named after and nested
|
||||
to an ancestor alignment's own rel_nests -- e.g. the same one already holding that
|
||||
ancestor's horizontal key points -- rather than the child's generic "Child of X" name."""
|
||||
file = _new_file()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
horizontal_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
horizontal_count = len(horizontal_nest.RelatedObjects)
|
||||
|
||||
ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
|
||||
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split
|
||||
child_alignment = alignment.IsDecomposedBy[0].RelatedObjects[-1]
|
||||
child_vertical = ifcopenshell.api.alignment.get_vertical_layout(child_alignment)
|
||||
|
||||
dp = file.createIfcAlignmentVerticalSegment(
|
||||
StartDistAlong=0.0,
|
||||
HorizontalLength=500.0,
|
||||
StartHeight=10.0,
|
||||
StartGradient=0.01,
|
||||
EndGradient=0.01,
|
||||
PredefinedType="CONSTANTGRADIENT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp)
|
||||
|
||||
result = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical, rel_nests=horizontal_nest)
|
||||
|
||||
assert result == horizontal_nest
|
||||
assert result.RelatingObject == alignment
|
||||
assert len(result.RelatedObjects) == horizontal_count + 2
|
||||
assert all(r.Name.startswith("A1 ") for r in result.RelatedObjects)
|
||||
assert not any("Child of" in r.Name for r in result.RelatedObjects)
|
||||
|
||||
|
||||
def test_returns_ifc_rel_nests():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
@@ -434,5 +399,4 @@ test_cant_layout_boundary_labels()
|
||||
test_no_real_segments_produces_no_referents()
|
||||
test_single_real_segment_produces_only_boundary_labels()
|
||||
test_start_station_composes_for_child_alignment()
|
||||
test_rel_nests_from_ancestor_used_for_naming_and_nesting()
|
||||
test_returns_ifc_rel_nests()
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 IfcOpenShell contributors
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.sequence
|
||||
import ifcopenshell.util.sequence
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestCreateBaseline(test.bootstrap.IFC4):
|
||||
def create_planned_schedule(self, name="Design & Build"):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
return ifcopenshell.api.sequence.add_work_schedule(self.file, name=name, predefined_type="PLANNED")
|
||||
|
||||
def test_returns_the_created_baseline_schedule(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Design")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
assert baseline.is_a("IfcWorkSchedule")
|
||||
assert baseline.Name == "Baseline 1"
|
||||
assert baseline.PredefinedType == "BASELINE"
|
||||
baseline_roots = ifcopenshell.util.sequence.get_root_tasks(baseline)
|
||||
assert [task.Name for task in baseline_roots] == [root_task.Name]
|
||||
assert baseline_roots != [root_task]
|
||||
|
||||
def test_falls_back_to_the_planned_schedule_name(self):
|
||||
planned = self.create_planned_schedule()
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned)
|
||||
|
||||
assert baseline.Name == "Design & Build"
|
||||
|
||||
def test_leaves_the_name_null_when_both_names_are_omitted(self):
|
||||
planned = self.create_planned_schedule()
|
||||
planned.Name = None
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned)
|
||||
|
||||
assert baseline.Name is None
|
||||
|
||||
def test_rejects_a_non_planned_schedule(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
actual = ifcopenshell.api.sequence.add_work_schedule(self.file, predefined_type="ACTUAL")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=actual)
|
||||
|
||||
def test_baselines_a_schedule_without_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
assert ifcopenshell.util.sequence.get_root_tasks(baseline) == []
|
||||
|
||||
def test_baselines_every_root_task(self):
|
||||
planned = self.create_planned_schedule()
|
||||
ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Design")
|
||||
ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_roots = ifcopenshell.util.sequence.get_root_tasks(baseline)
|
||||
assert sorted(task.Name for task in baseline_roots) == ["Construction", "Design"]
|
||||
|
||||
def test_baselines_nested_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
|
||||
ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Superstructure")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
nested = ifcopenshell.util.sequence.get_nested_tasks(baseline_root)
|
||||
assert sorted(task.Name for task in nested) == ["Foundations", "Superstructure"]
|
||||
assert len(self.file.by_type("IfcTask")) == 6
|
||||
|
||||
def test_baselines_task_attributes_and_times(self):
|
||||
planned = self.create_planned_schedule()
|
||||
task = ifcopenshell.api.sequence.add_task(
|
||||
self.file, work_schedule=planned, name="Foundations", identification="A1", description="Pour concrete"
|
||||
)
|
||||
ifcopenshell.api.sequence.add_task_time(self.file, task=task)
|
||||
ifcopenshell.api.sequence.edit_task_time(
|
||||
self.file, task_time=task.TaskTime, attributes={"ScheduleDuration": "P5D"}
|
||||
)
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_task = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
assert baseline_task.Identification == "A1"
|
||||
assert baseline_task.Description == "Pour concrete"
|
||||
assert baseline_task.TaskTime != task.TaskTime
|
||||
assert baseline_task.TaskTime.ScheduleDuration == "P5D"
|
||||
|
||||
def test_baselines_sequence_relationships_between_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
predecessor = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
|
||||
successor = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Superstructure")
|
||||
ifcopenshell.api.sequence.assign_sequence(self.file, relating_process=predecessor, related_process=successor)
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
nested = {task.Name: task for task in ifcopenshell.util.sequence.get_nested_tasks(baseline_root)}
|
||||
rels = nested["Foundations"].IsPredecessorTo
|
||||
assert len(rels) == 1
|
||||
assert rels[0].RelatedProcess == nested["Superstructure"]
|
||||
|
||||
def test_references_the_planned_schedule_and_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
subtask = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
baseline_subtask = ifcopenshell.util.sequence.get_nested_tasks(baseline_root)[0]
|
||||
references = {
|
||||
rel.RelatingObject: list(rel.RelatedObjects) for rel in self.file.by_type("IfcRelDefinesByObject")
|
||||
}
|
||||
assert references[planned] == [baseline]
|
||||
assert references[root_task] == [baseline_root]
|
||||
assert references[subtask] == [baseline_subtask]
|
||||
|
||||
def test_reuses_the_existing_reference_for_further_baselines(self):
|
||||
planned = self.create_planned_schedule()
|
||||
|
||||
first = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
second = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 2")
|
||||
|
||||
assert len(planned.Declares) == 1
|
||||
assert list(planned.Declares[0].RelatedObjects) == [first, second]
|
||||
@@ -86,69 +86,7 @@ def _test_us_stations():
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
|
||||
def _test_custom_named_conversion_based_unit_stations():
|
||||
"""Regression test: station_as_string() must work for an
|
||||
IfcConversionBasedUnit whose Name isn't one of the fixed set
|
||||
ifcopenshell.util.unit.si_conversions recognises (e.g. a project that,
|
||||
reasonably, names its foot-based unit something other than the bare
|
||||
"foot" IfcOpenShell's own add_conversion_based_unit() produces -- for
|
||||
instance to distinguish the US survey foot, 1200/3937 m exactly, from
|
||||
the international foot, 0.3048 m exactly, which differ by ~2 ppm and
|
||||
are NOT interchangeable once a project is tied to a US state plane CRS,
|
||||
virtually all of which are defined in US survey feet).
|
||||
|
||||
Previously, station_as_string() converted via
|
||||
ifcopenshell.util.unit.convert(), which looks up the conversion factor
|
||||
BY NAME in si_conversions -- silently substituting a factor of 1.0
|
||||
(i.e. treating the value as if it were already in the display unit) for
|
||||
any unrecognised name, rather than raising an error. For a project unit
|
||||
like "US survey foot" this inflated every station string by the
|
||||
project-unit<->metre ratio (~3.28x), even though the underlying
|
||||
Pset_Stationing.Station numeric value written by
|
||||
ifcopenshell.api.alignment.create()/update_key_point_referents was
|
||||
correct throughout -- only the display text was wrong.
|
||||
"""
|
||||
file = ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
|
||||
# Hand-built rather than via add_conversion_based_unit(), since that
|
||||
# API also resolves its conversion factor by name (si_conversions) and
|
||||
# can't produce a custom name paired with a specific factor.
|
||||
si_unit = file.createIfcSIUnit(UnitType="LENGTHUNIT", Name="METRE")
|
||||
value_component = file.create_entity("IfcReal", wrappedValue=1200.0 / 3937.0) # US survey foot, exact
|
||||
conversion_factor = file.createIfcMeasureWithUnit(value_component, si_unit)
|
||||
exponents = file.createIfcDimensionalExponents(1, 0, 0, 0, 0, 0, 0)
|
||||
length = file.createIfcConversionBasedUnit(exponents, "LENGTHUNIT", "US survey foot", conversion_factor)
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
|
||||
# US survey foot and international foot differ by ~2 ppm. At small
|
||||
# station values that's invisible at 2-decimal-place precision, so
|
||||
# these match _test_us_stations()'s "foot" case exactly.
|
||||
s = sta.station_as_string(file, 0.0)
|
||||
assert s == "0+00.00"
|
||||
|
||||
s = sta.station_as_string(file, 100.00)
|
||||
assert s == "1+00.00"
|
||||
|
||||
s = sta.station_as_string(file, -100.00)
|
||||
assert s == "-1+00.00"
|
||||
|
||||
# At a large enough station, ~2 ppm DOES become visible at 2 decimal
|
||||
# places (123456.789 * 2e-6 =~ 0.25) -- this is the real, correct US
|
||||
# survey foot vs. international foot difference, not a bug. Before the
|
||||
# fix, the name-based lookup's silent 1.0 fallback inflated this same
|
||||
# input by ~3.28x to "1234+57.036" -> "4050+82.90"-ish territory, wildly
|
||||
# different from either correct answer -- so this still exercises the
|
||||
# regression, it's just not identical to the "foot" case's value.
|
||||
s = sta.station_as_string(file, 123456.789)
|
||||
assert s == "1234+57.04"
|
||||
|
||||
s = sta.station_as_string(file, -123456.789)
|
||||
assert s == "-1234+57.04"
|
||||
|
||||
|
||||
def test_station_as_string():
|
||||
_test_si_stations()
|
||||
_test_si_stations_millimeter()
|
||||
_test_us_stations()
|
||||
_test_custom_named_conversion_based_unit_stations()
|
||||
|
||||
@@ -1392,47 +1392,3 @@ class TestCopyDeepIFC4(test.bootstrap.IFC4):
|
||||
element2 = subject.copy_deep(self.file, element)
|
||||
assert element2.Segments[0][0] == (1, 2)
|
||||
assert element2.Segments[1][0] == (3, 4)
|
||||
|
||||
|
||||
class TestIterTopConnections(test.bootstrap.IFC4):
|
||||
def test_yields_top_connected_element(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
rel = self.file.createIfcRelConnectsElements(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingElement=slab,
|
||||
RelatedElement=wall,
|
||||
Description="TOP",
|
||||
)
|
||||
results = list(subject.iter_top_connections(wall))
|
||||
assert len(results) == 1
|
||||
assert results[0][0] == slab
|
||||
assert results[0][1] == rel
|
||||
|
||||
def test_returns_empty_when_no_connections(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
assert list(subject.iter_top_connections(wall)) == []
|
||||
|
||||
def test_filters_non_top_description(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
self.file.createIfcRelConnectsElements(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingElement=slab,
|
||||
RelatedElement=wall,
|
||||
Description="BOTTOM",
|
||||
)
|
||||
assert list(subject.iter_top_connections(wall)) == []
|
||||
|
||||
def test_filters_non_rel_connects_elements(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
self.file.createIfcRelConnectsPathElements(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingElement=slab,
|
||||
RelatedElement=wall,
|
||||
Description="ATPATH",
|
||||
RelatingConnectionType="ATPATH",
|
||||
RelatedConnectionType="ATPATH",
|
||||
)
|
||||
assert list(subject.iter_top_connections(wall)) == []
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell.util.shape as subject
|
||||
|
||||
|
||||
def _cube_verts_faces(size=2.0, z_offset=0.0):
|
||||
"""Build a triangulated cube as (verts, faces) numpy arrays."""
|
||||
s = size / 2
|
||||
verts = np.array(
|
||||
[
|
||||
[-s, -s, -s + z_offset],
|
||||
[s, -s, -s + z_offset],
|
||||
[s, s, -s + z_offset],
|
||||
[-s, s, -s + z_offset],
|
||||
[-s, -s, s + z_offset],
|
||||
[s, -s, s + z_offset],
|
||||
[s, s, s + z_offset],
|
||||
[-s, s, s + z_offset],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
faces = np.array(
|
||||
[
|
||||
[0, 1, 2],
|
||||
[0, 2, 3],
|
||||
[4, 6, 5],
|
||||
[4, 7, 6],
|
||||
[0, 4, 5],
|
||||
[0, 5, 1],
|
||||
[1, 5, 6],
|
||||
[1, 6, 2],
|
||||
[2, 6, 7],
|
||||
[2, 7, 3],
|
||||
[3, 7, 4],
|
||||
[3, 4, 0],
|
||||
],
|
||||
dtype=np.int32,
|
||||
)
|
||||
return verts, faces
|
||||
|
||||
|
||||
class TestBisectMeshPlaneVf:
|
||||
def test_bisect_at_mid_height(self):
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
segments = subject.bisect_mesh_plane_vf(verts, faces, plane_z=0.0)
|
||||
assert len(segments) >= 4
|
||||
for start, end in segments:
|
||||
assert len(start) == 2
|
||||
assert len(end) == 2
|
||||
|
||||
def test_bisect_above_mesh_returns_empty(self):
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
segments = subject.bisect_mesh_plane_vf(verts, faces, plane_z=10.0)
|
||||
assert segments == []
|
||||
|
||||
def test_bisect_below_mesh_returns_empty(self):
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
segments = subject.bisect_mesh_plane_vf(verts, faces, plane_z=-10.0)
|
||||
assert segments == []
|
||||
|
||||
def test_bisect_with_extend(self):
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
segments_no_extend = subject.bisect_mesh_plane_vf(verts, faces, plane_z=0.0, extend=0.0)
|
||||
segments_extend = subject.bisect_mesh_plane_vf(verts, faces, plane_z=0.0, extend=0.05)
|
||||
assert len(segments_extend) == len(segments_no_extend)
|
||||
for (s_ext, e_ext), (s_no, e_no) in zip(segments_extend, segments_no_extend):
|
||||
assert abs(s_ext[0] - s_no[0]) >= 0.04 or abs(s_ext[1] - s_no[1]) >= 0.04
|
||||
|
||||
def test_bisect_empty_faces(self):
|
||||
verts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64)
|
||||
faces = np.array([], dtype=np.int32).reshape(0, 3)
|
||||
assert subject.bisect_mesh_plane_vf(verts, faces, plane_z=0.0) == []
|
||||
|
||||
def test_bisect_precision(self):
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
segments = subject.bisect_mesh_plane_vf(verts, faces, plane_z=0.0, precision=6)
|
||||
for start, end in segments:
|
||||
for coord in start + end:
|
||||
assert round(coord, 6) == coord
|
||||
@@ -1,186 +0,0 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.space as subject
|
||||
import pytest
|
||||
import shapely
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
def _build_shapes_dict(ifc_file, elements):
|
||||
"""Build a shapes dict as expected by ifcopenshell.util.space functions."""
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("disable-opening-subtractions", True)
|
||||
settings.set("use-world-coords", True)
|
||||
shapes = {}
|
||||
for element in elements:
|
||||
shape = ifcopenshell.geom.create_shape(settings, element)
|
||||
verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry)
|
||||
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
|
||||
zs = verts[:, 2]
|
||||
shapes[element.id()] = {
|
||||
"verts": verts,
|
||||
"faces": faces,
|
||||
"bottom_z": float(zs.min()),
|
||||
"top_z": float(zs.max()),
|
||||
}
|
||||
return shapes
|
||||
|
||||
|
||||
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
|
||||
"""Add a body representation (extruded polyline) to an element."""
|
||||
if not ifc_file.by_type("IfcProject"):
|
||||
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
|
||||
ctx = ifc_file.createIfcGeometricRepresentationContext(
|
||||
ContextType="Model",
|
||||
CoordinateSpaceDimension=3,
|
||||
Precision=1e-5,
|
||||
WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
),
|
||||
)
|
||||
sub_ctx = ifc_file.createIfcGeometricRepresentationSubContext(
|
||||
ContextIdentifier="Body",
|
||||
ContextType="Model",
|
||||
ParentContext=ctx,
|
||||
TargetView="MODEL_VIEW",
|
||||
)
|
||||
pts = [ifc_file.createIfcCartesianPoint((float(x), float(y))) for x, y in coords_2d]
|
||||
polyline = ifc_file.createIfcPolyline(pts)
|
||||
profile = ifc_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="CURVE", OuterCurve=polyline)
|
||||
placement = ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint((0.0, 0.0, z_offset)),
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
)
|
||||
direction = ifc_file.createIfcDirection((0.0, 0.0, 1.0))
|
||||
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
|
||||
rep = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=sub_ctx,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[solid],
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=rep)
|
||||
|
||||
|
||||
class TestGetBoundaryLines(test.bootstrap.IFC4):
|
||||
def test_returns_segments_for_intersecting_walls(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
lines, bounding = subject.get_boundary_lines(self.file, shapes, cut_z=1.0)
|
||||
assert len(lines) > 0
|
||||
assert wall in bounding
|
||||
|
||||
def test_skips_elements_not_intersecting_plane(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, wall, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 1.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
lines, bounding = subject.get_boundary_lines(self.file, shapes, cut_z=10.0)
|
||||
assert lines == []
|
||||
assert bounding == []
|
||||
|
||||
def test_skips_non_bounding_classes(self):
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, slab, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 1.0)
|
||||
shapes = _build_shapes_dict(self.file, [slab])
|
||||
lines, bounding = subject.get_boundary_lines(self.file, shapes, cut_z=0.5)
|
||||
assert slab not in bounding
|
||||
|
||||
|
||||
class TestGetSpacePolygon(test.bootstrap.IFC4):
|
||||
def test_finds_containing_polygon(self):
|
||||
lines = [
|
||||
shapely.LineString([(0, 0), (10, 0)]),
|
||||
shapely.LineString([(10, 0), (10, 10)]),
|
||||
shapely.LineString([(10, 10), (0, 10)]),
|
||||
shapely.LineString([(0, 10), (0, 0)]),
|
||||
]
|
||||
polygon, _ = subject.get_space_polygon(lines, 5, 5)
|
||||
assert not isinstance(polygon, str)
|
||||
assert polygon.area == pytest.approx(100)
|
||||
|
||||
def test_no_polygons_found(self):
|
||||
polygon, _ = subject.get_space_polygon([], 0, 0)
|
||||
assert polygon == "NO POLYGONS FOUND"
|
||||
|
||||
def test_no_polygon_for_point(self):
|
||||
lines = [
|
||||
shapely.LineString([(0, 0), (10, 0)]),
|
||||
shapely.LineString([(10, 0), (10, 10)]),
|
||||
shapely.LineString([(10, 10), (0, 10)]),
|
||||
shapely.LineString([(0, 10), (0, 0)]),
|
||||
]
|
||||
polygon, _ = subject.get_space_polygon(lines, 50, 50)
|
||||
assert polygon == "NO POLYGON FOR POINT"
|
||||
|
||||
|
||||
class TestGetAutoSpaceHeight(test.bootstrap.IFC4):
|
||||
def test_height_from_top_connection(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
self.file.createIfcRelConnectsElements(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingElement=slab,
|
||||
RelatedElement=wall,
|
||||
Description="TOP",
|
||||
)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [wall])
|
||||
assert height is not None
|
||||
assert height == pytest.approx(3.0, abs=0.1)
|
||||
|
||||
def test_height_from_elements_above_without_top_connection(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [wall])
|
||||
assert height is not None
|
||||
assert height == pytest.approx(3.0, abs=0.1)
|
||||
|
||||
def test_height_from_wall_tops_when_no_slab(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [wall])
|
||||
assert height is not None
|
||||
assert height == pytest.approx(3.0, abs=0.1)
|
||||
|
||||
def test_returns_none_when_no_elements_above(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
space_polygon = shapely.box(-100, -100, -90, -90)
|
||||
height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [])
|
||||
assert height is None
|
||||
+26
-11
@@ -53,6 +53,16 @@ QT_DEPLOYMENT_DLLS = {
|
||||
"vulkan-1.dll",
|
||||
}
|
||||
QT_CONF = "[Paths]\nPrefix = .\n"
|
||||
# Runtime plugins are canonically prefixed with 'ifcopenshell_' (see
|
||||
# decorated_basename() in src/plugin/plugin.cpp and the OUTPUT_NAME properties of
|
||||
# the plugin targets, e.g. 'ifcopenshell_parse_schema_ifc${schema}'), while the
|
||||
# core shared libraries keep the dotted 'ifcopenshell.' names. Match both so the
|
||||
# load-by-name plugins are not silently dropped from the archives.
|
||||
IFC_RUNTIME_PLUGIN_PREFIXES = ("ifcopenshell.", "ifcopenshell_")
|
||||
# Per-schema geometry writers ship with the Python package only, not next to the
|
||||
# executables. 'ifcopenshell.geometry.writer.' covers the core library, the
|
||||
# underscore form covers the per-schema plugins.
|
||||
IFC_GEOMETRY_WRITER_PREFIXES = ("ifcopenshell.geometry.writer.", "ifcopenshell_geometry_writer_")
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
@@ -153,6 +163,19 @@ def trace_runtime_dependencies(roots: set[Path], candidates: set[Path]) -> set[P
|
||||
return resolved
|
||||
|
||||
|
||||
def is_geometry_writer(file: Path) -> bool:
|
||||
return file.name.startswith(IFC_GEOMETRY_WRITER_PREFIXES)
|
||||
|
||||
|
||||
def collect_ifc_runtime_plugins(dlls: set[Path], dependencies: set[Path]) -> set[Path]:
|
||||
"""IfcOpenShell plugins are loaded by name at runtime, so dumpbin cannot discover them."""
|
||||
return {
|
||||
d
|
||||
for d in (dlls - dependencies)
|
||||
if d.name.startswith(IFC_RUNTIME_PLUGIN_PREFIXES) and not is_geometry_writer(d)
|
||||
}
|
||||
|
||||
|
||||
def is_qt_deployment_dll(file: Path) -> bool:
|
||||
name = file.name.lower()
|
||||
return name.startswith("qt") or name.startswith("d3dcompiler_") or name in QT_DEPLOYMENT_DLLS
|
||||
@@ -223,11 +246,7 @@ def archive_executables() -> None:
|
||||
exes = {file for file in bin_files if file.suffix.lower() == ".exe"}
|
||||
dlls = {file for file in bin_files if file.suffix.lower() == ".dll"}
|
||||
dependencies = trace_runtime_dependencies(exes, dlls)
|
||||
ifc_runtime_plugins = {
|
||||
d
|
||||
for d in (set(dlls) - dependencies)
|
||||
if d.name.startswith("ifcopenshell.") and not d.name.startswith("ifcopenshell.geometry.writer.")
|
||||
}
|
||||
ifc_runtime_plugins = collect_ifc_runtime_plugins(dlls, dependencies)
|
||||
qt_deployment_files = collect_qt_deployment_files(install_dir)
|
||||
|
||||
for file in sorted(exes):
|
||||
@@ -267,12 +286,8 @@ def archive_python_package(python_version: str, python_path: Path) -> None:
|
||||
exes = {file for file in bin_files if file.suffix.lower() == ".exe"}
|
||||
dlls = {file for file in bin_files if file.suffix.lower() == ".dll"}
|
||||
dependencies = trace_runtime_dependencies(exes, dlls)
|
||||
ifc_runtime_plugins = {
|
||||
d
|
||||
for d in (set(dlls) - dependencies)
|
||||
if d.name.startswith("ifcopenshell.") and not d.name.startswith("ifcopenshell.geometry.writer.")
|
||||
}
|
||||
geometry_writing = {f for f in bin_files if f.name.startswith("ifcopenshell.geometry.writer.")}
|
||||
ifc_runtime_plugins = collect_ifc_runtime_plugins(dlls, dependencies)
|
||||
geometry_writing = {f for f in bin_files if is_geometry_writer(f)}
|
||||
|
||||
python_version_major_minor = "".join(python_version.split(".")[:2])
|
||||
site_packages = python_path / "Lib" / "site-packages"
|
||||
|
||||
Reference in New Issue
Block a user