Compare commits

..

1 Commits

Author SHA1 Message Date
DesertSpringsCivil 588557ae00 win: collect underscore-prefixed runtime plugin DLLs in build artifacts
Runtime plugins are canonically named `ifcopenshell_<kind>_<name>` (decorated_basename() in src/plugin/plugin.cpp, and the OUTPUT_NAME properties of the plugin targets), but the archive collection filtered on the dotted `ifcopenshell.` prefix, which matches only the core shared libraries. Every load-by-name plugin was therefore silently dropped from every win64 / win-arm64 zip.

Accept both prefixes, and extend the geometry-writer exclusion to the underscore form so the per-schema writers keep their existing Python-package-only treatment.

Fixes #9301
2026-08-14 21:31:53 -06:00
38 changed files with 308 additions and 3309 deletions
-3
View File
@@ -17,6 +17,3 @@
[submodule "src/svgfill/3rdparty/svgpp"]
path = src/svgfill/3rdparty/svgpp
url = https://github.com/svgpp/svgpp
[submodule "src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles"]
path = src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles
url = https://github.com/CyrilWaechter/IfcRelSpaceBoundary_TestFiles
-19
View File
@@ -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,
+195 -51
View File
@@ -18,7 +18,7 @@
import logging
import multiprocessing
from math import inf, pi
from math import acos, degrees, inf, pi, radians
from typing import Optional, Union
import bmesh
@@ -28,7 +28,6 @@ import ifcopenshell.api.boundary
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.boundary
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.shape
@@ -40,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
@@ -424,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"
@@ -696,30 +668,36 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
def auto_generate_boundaries(
self, space: ifcopenshell.entity_instance, space_obj: bpy.types.Object
) -> Union[str, list[ifcopenshell.entity_instance]]:
"""Generate boundaries by delegating to ifcopenshell.util.boundary.
This method handles Blender-specific preprocessing (flushing moved
objects, building the geometry cache + spatial tree) then delegates
the algorithm to the Blender-independent util module.
"""
:return: list of created boundaries or a string with error description.
"""
ifc_file = tool.Ifc.get()
props = tool.Model.get_model_props()
boundaries: list[ifcopenshell.entity_instance] = []
assert isinstance(space_obj.data, bpy.types.Mesh)
# Identify all potential building elements
building_elements = []
for ifc_class in ifcopenshell.util.boundary.BOUNDARY_ELEMENT_CLASSES:
building_elements.extend(ifc_file.by_type(ifc_class))
# TODO: don't select everything, use AABB culling in Blender
building_elements = list(
tool.Ifc.get().by_type("IfcWall")
+ tool.Ifc.get().by_type("IfcSlab")
+ tool.Ifc.get().by_type("IfcVirtualElement")
)
# Flush moved objects to IFC
for building_element in building_elements:
if obj := tool.Ifc.get_object(building_element):
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
if tool.Ifc.is_moved(space_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=space_obj)
# Build shapes dict with iterator (parallel, includes space + building elements)
# Don't generate boundaries of building elements that we've already got bounaries for.
for boundary in space.BoundedBy:
if boundary.RelatedBuildingElement in building_elements:
building_elements.remove(boundary.RelatedBuildingElement)
# Create tree of gross shapes of all potential related building elements
include = building_elements + [space]
tree = ifcopenshell.geom.tree()
shapes = {}
@@ -734,23 +712,189 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
shapes[shape.id] = {
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
}
if not iterator.next():
break
# Pass all building element shapes to the auto-generation function.
# The function performs its own spatial filtering (coplanarity + overlap),
# so tree-adjacency filtering is not needed here.
filtered_shapes = {space.id(): shapes[space.id()]}
for element in building_elements:
if element.id() in shapes:
filtered_shapes[element.id()] = shapes[element.id()]
# Spatially query all potential boundary elements via a 100mm extension of the space
building_elements = [e for e in tree.select(space, extend=0.1) if e != space]
return ifcopenshell.util.boundary.auto_generate_boundaries(
ifc_file, space, filtered_shapes, props.boundary_class
)
if not building_elements:
return "No building elements found to create boundaries."
# Create a dissolved bmesh for the space
space_bm = bmesh.new()
space_bm.from_mesh(space_obj.data)
bmesh.ops.dissolve_limit(space_bm, angle_limit=pi * 2 / 360, verts=space_bm.verts[:], edges=space_bm.edges[:])
# Create dissolved bmeshes for all boundary elements
building_element_bms = {}
for building_element in building_elements:
bm = bmesh.new()
shape = shapes[building_element.id()]
for vert in shape["verts"]:
bm.verts.new(Vector(vert))
bm.verts.ensure_lookup_table()
for face in shape["faces"]:
bm.faces.new([bm.verts[i] for i in face])
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
bm.normal_update() # Needed so that dissolve_limit will work.
bmesh.ops.dissolve_limit(bm, angle_limit=radians(1), verts=bm.verts[:], edges=bm.edges[:])
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
building_element_bms[building_element.id()] = bm
# Compare space faces and building element faces to see if they relate to one another
for space_face in space_bm.faces:
space_face_normal = space_obj.matrix_world.to_3x3() @ space_face.normal
space_face_vert = space_obj.matrix_world @ space_face.verts[0].co
for building_element in building_elements:
for face in building_element_bms[building_element.id()].faces:
building_obj = tool.Ifc.get_object(building_element)
face_normal = building_obj.matrix_world.to_3x3() @ face.normal
angle = degrees(acos(max(min(space_face_normal.dot(face_normal), 1), -1)))
if tool.Cad.is_x(angle, 180, tolerance=2):
pass # Faces need to be parallel and have opposite normals to be related.
elif building_element.is_a("IfcVirtualElement") and tool.Cad.is_x(angle, 0, tolerance=2):
pass # Virtual elements only need to be parallel to be related, since they are planes.
else:
continue
# Both faces should be close to one another. Say within 50mm.
space_vert = building_obj.matrix_world.inverted() @ space_face_vert
dist = mathutils.geometry.distance_point_to_plane(space_vert, face.verts[0].co, face.normal)
if abs(dist) > 0.05:
continue
# Project the building element face onto the space face
space_face_verts = [v.co.copy() for v in space_face.verts]
space_face_matrix = self.get_face_matrix(*[v.copy() for v in space_face_verts[0:3]])
space_face_matrix_i = space_face_matrix.inverted()
space_face_polygon = shapely.Polygon(
[tuple((space_face_matrix_i @ v).xy) for v in space_face_verts]
)
space_matrix_world_i = space_obj.matrix_world.inverted()
face_verts = [space_matrix_world_i @ building_obj.matrix_world @ v.co.copy() for v in face.verts]
face_polygon = shapely.Polygon([tuple((space_face_matrix_i @ v).xy) for v in face_verts])
gross_boundary_polygon = space_face_polygon.intersection(face_polygon)
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if (
not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid)
or gross_boundary_polygon.is_empty
):
continue
# The gross boundary polygon may not be a true gross boundary since it
# may have openings already removed, such as in IFC4 Reference View. So
# we cheat by using the exterior boundary to mean "gross".
exterior_boundary_polygon = shapely.Polygon(gross_boundary_polygon.exterior.coords)
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=props.boundary_class)
if building_element.is_a("IfcVirtualElement"):
parent_boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
else:
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
if building_element.is_a("IfcWall"):
is_external = ifcopenshell.util.element.get_pset(
building_element, "Pset_WallCommon", "IsExternal"
)
if is_external is True:
parent_boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
parent_boundary.InternalOrExternalBoundary = "INTERNAL"
elif building_element.is_a("IfcSlab"):
predefined_type = ifcopenshell.util.element.get_predefined_type(building_element)
if predefined_type == "BASESLAB":
parent_boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH"
else:
is_external = ifcopenshell.util.element.get_pset(
building_element, "Pset_SlabCommon", "IsExternal"
)
if is_external is True:
parent_boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
parent_boundary.InternalOrExternalBoundary = "INTERNAL"
parent_boundary.RelatingSpace = space
parent_boundary.RelatedBuildingElement = building_element
parent_boundary.ConnectionGeometry = self.create_connection_geometry_from_polygon(
exterior_boundary_polygon, space_face_matrix
)
self.set_boundary_name(parent_boundary)
boundaries.append(parent_boundary)
for rel in getattr(building_element, "HasOpenings", []):
opening = rel.RelatedOpeningElement
filling = opening.HasFillings[0].RelatedBuildingElement if opening.HasFillings else None
# Create shape of opening as a dissolved BMesh
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, opening)
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
opening_bm = bmesh.new()
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
for vert in verts:
opening_bm.verts.new(Vector(vert))
opening_bm.verts.ensure_lookup_table()
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
for face in faces:
opening_bm.faces.new([opening_bm.verts[i] for i in face])
opening_bm.verts.ensure_lookup_table()
opening_bm.faces.ensure_lookup_table()
opening_bm.normal_update() # Needed so that dissolve_limit will work.
bmesh.ops.dissolve_limit(
opening_bm, angle_limit=radians(1), verts=opening_bm.verts[:], edges=opening_bm.edges[:]
)
opening_bm.verts.ensure_lookup_table()
opening_bm.faces.ensure_lookup_table()
# Get relevant faces of BMesh that can turn into boundaries
opening_polygons = []
for opening_face in opening_bm.faces:
opening_face_normal = mat.to_3x3() @ opening_face.normal
angle = degrees(acos(max(min(opening_face_normal.dot(face_normal), 1), -1)))
if not tool.Cad.is_x(angle, 180, tolerance=2):
continue # Any non-parallel faces are not relevant
opening_face_verts = [space_matrix_world_i @ mat @ v.co.copy() for v in opening_face.verts]
polygon = shapely.Polygon([tuple((space_face_matrix_i @ v).xy) for v in opening_face_verts])
opening_polygons.append(polygon)
# Merge them all into a single opening polygon for our boundary
opening_polygon = shapely.ops.unary_union(opening_polygons)
# Only openings that are projected onto our exterior boundary are relevant.
if opening_polygon.intersection(exterior_boundary_polygon).area == 0:
continue
boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=props.boundary_class)
boundary.RelatingSpace = space
boundary.RelatedBuildingElement = filling or opening
boundary.ConnectionGeometry = self.create_connection_geometry_from_polygon(
opening_polygon, space_face_matrix
)
if filling:
boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
else:
boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
boundary.InternalOrExternalBoundary = parent_boundary.InternalOrExternalBoundary
if boundary.is_a() != "IfcRelSpaceBoundary":
boundary.ParentBoundary = parent_boundary
self.set_boundary_name(boundary)
boundaries.append(boundary)
return boundaries
def create_element_boundary(
self,
+2 -8
View File
@@ -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(
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+5 -34
View File
@@ -20,10 +20,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Union
import ifcopenshell
if TYPE_CHECKING:
import bpy
import ifcopenshell
import bonsai.tool as tool
@@ -187,6 +186,9 @@ def generate_space(
"""
:return: None if successful, error message string if not.
"""
if not root.get_default_container():
raise SpaceGenerationError("Please set a default container to create the space in.")
active_obj = spatial.get_active_obj()
selected_objects = spatial.get_selected_objects()
element = None
@@ -204,14 +206,7 @@ def generate_space(
else:
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
if element and element.is_a("IfcSpace"):
container = ifcopenshell.util.element.get_parent(element) or root.get_default_container()
else:
container = root.get_default_container()
if not container:
raise SpaceGenerationError("Please set a default container to create the space in.")
space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y, container=container)
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
if isinstance(space_polygon, str):
if space_polygon == "NO POLYGONS FOUND":
@@ -225,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:
@@ -263,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)
+18 -164
View File
@@ -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,124 +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, container: Optional[ifcopenshell.entity_instance] = None) -> 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
if container is None:
container = tool.Root.get_default_container()
container_obj = tool.Ifc.get_object(container)
cut_z = container_obj.matrix_world.translation.z + calculation_rl
# Commit any moved visible bounding objects before reading IFC geometry,
# so the IFC-based cache uses the current Blender positions.
for obj in bpy.context.visible_objects:
element = tool.Ifc.get_entity(obj)
if element is None or not any(element.is_a(c) for c in ifcopenshell.util.space.BOUNDING_CLASSES):
continue
tool.Geometry.commit_placement_if_moved(obj)
cls._geom_cache.clear()
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:
@@ -952,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()
@@ -962,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)
@@ -976,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:
@@ -1016,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:
+10 -3
View File
@@ -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):
-112
View File
@@ -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)
+15 -189
View File
@@ -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
@@ -1,983 +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 IfcRelSpaceBoundary generation from IFC geometry.
These functions operate on IFC geometry data (vertices, faces, edges,
element relationships) without requiring any Blender objects to be loaded.
"""
from __future__ import annotations
import logging
from math import acos, degrees
from typing import Optional, Union
import ifcopenshell
import ifcopenshell.api.boundary
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder as sb
import ifcopenshell.util.unit
import numpy as np
import shapely
import shapely.ops
logger = logging.getLogger("ImportIFC")
BOUNDARY_ELEMENT_CLASSES = (
"IfcWall",
"IfcColumn",
"IfcSlab",
"IfcRoof",
"IfcVirtualElement",
"IfcCurtainWall",
"IfcWindow",
"IfcDoor",
)
# Plane offset (in meters) below which a sole bounding element is assigned the
# full space face. Building element faces are often slightly offset from the
# space face they bound (e.g. wall linings), so a single bounding element
# within this offset gets the complete face rather than a clipped polygon.
FULL_FACE_OFFSET_TOL = 0.25
def _union_coplanar_face_polygon(
space_verts_local,
space_triangles,
space_triangle_normals,
face_origin,
face_normal,
face_matrix_inv,
fallback,
):
"""Reconstruct a space face from its raw triangles.
``dissolve_faces`` with ``merge_coplanar=True`` drops any interior rings
(holes) when coplanar faces are merged, e.g. a ceiling pierced by a shaft
or an opening. Unioning the raw triangles coplanar with the face restores
those holes.
"""
# Vectorized coplanarity prefilter: keep only triangles whose vertices all
# lie within 1e-4 m (0.1 mm) of the face plane.
triangle_points = space_verts_local[space_triangles]
plane_offsets = np.abs(np.tensordot(triangle_points - face_origin, face_normal, axes=(2, 0))).max(axis=1)
polygons = []
for triangle in np.flatnonzero(plane_offsets <= 1e-4):
if space_triangle_normals[triangle] is None:
continue
polygon = _verts_to_polygon(space_verts_local[space_triangles[triangle]], face_matrix_inv, snap=1e-6)
if not polygon.is_valid:
polygon = polygon.buffer(0)
if polygon.is_empty:
continue
polygons.append(polygon)
if not polygons:
return fallback
union = shapely.ops.unary_union(polygons).buffer(0)
if isinstance(union, shapely.Polygon):
return union
if isinstance(union, shapely.MultiPolygon):
best, best_area = None, -1.0
for polygon in union.geoms:
overlap = polygon.intersection(fallback).area
if overlap > best_area:
best, best_area = polygon, overlap
return best if best is not None else fallback
return fallback
def auto_generate_boundaries(
ifc_file: ifcopenshell.file,
space: ifcopenshell.entity_instance,
shapes: dict,
boundary_class: str,
boundary_element_classes: tuple = BOUNDARY_ELEMENT_CLASSES,
) -> Union[str, list[ifcopenshell.entity_instance]]:
"""Generate IfcRelSpaceBoundary records from IFC geometry without Blender.
:param ifc_file: The IFC file.
:param space: The IfcSpace entity to generate boundaries for.
:param shapes: Dict ``{element_id: {"verts": ndarray, "faces": ndarray,
"edges": ndarray, "matrix": ndarray}}``. Must include the space itself.
Built by the caller via ``ifcopenshell.geom.iterator``.
:param boundary_class: IFC class for boundaries (e.g.
``"IfcRelSpaceBoundary2ndLevel"``).
:param boundary_element_classes: IFC classes to consider as boundary elements.
:return: List of created ``IfcRelSpaceBoundary`` entities, or error string.
"""
boundaries: list[ifcopenshell.entity_instance] = []
space_shape = shapes.get(space.id())
if space_shape is None:
return "Space geometry not found in shapes dict."
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
# Identify all potential building elements
building_elements = []
for ifc_class in boundary_element_classes:
building_elements.extend(ifc_file.by_type(ifc_class))
# Delete existing boundaries so they are regenerated. remove_deep2 cannot be
# used on the boundary itself because 2nd level boundaries are referenced via
# their ParentBoundary and CorrelationId attributes by other boundaries.
for boundary in list(space.BoundedBy or []):
if boundary.RelatedBuildingElement in building_elements:
ifcopenshell.api.boundary.remove_boundary(ifc_file, boundary)
# Filter to elements that have shapes in the cache
building_elements = [e for e in building_elements if e.id() in shapes]
if not building_elements:
return "No building elements found to create boundaries."
# Dissolve space mesh — verts are in local coords, matrix is the placement
space_matrix = space_shape["matrix"]
space_matrix_3x3 = space_matrix[:3, :3]
space_matrix_inv = np.linalg.inv(space_matrix)
# Space verts are already local (get_vertices without use-world-coords)
space_verts_local = space_shape["verts"]
space_ngons = ifcopenshell.util.shape.dissolve_faces(
space_verts_local, space_shape["faces"], space_shape["edges"], merge_coplanar=True
)
# Per-triangle normals used to reconstruct space faces from their raw
# triangles (see _union_coplanar_face_polygon). Computed once instead of
# once per space face.
space_triangle_normals = [_face_normal(space_verts_local[tri]) for tri in space_shape["faces"]]
# Dissolve building element meshes — verts are in element-local coords
element_ngons = {}
for element in building_elements:
es = shapes[element.id()]
element_ngons[element.id()] = ifcopenshell.util.shape.dissolve_faces(
es["verts"], es["faces"], es["edges"], merge_coplanar=True
)
# Separate from processed_fillings (used by _process_openings) so that
# pre-populating does not cause _process_openings to skip fillings.
all_filling_ids: set[int] = set()
for element in building_elements:
for rel in getattr(element, "HasOpenings", []):
if not (opening := rel.RelatedOpeningElement).HasFillings:
continue
for fills_rel in opening.HasFillings:
all_filling_ids.add(fills_rel.RelatedBuildingElement.id())
# Some models have openings without an IfcRelFillsElement relation (e.g. a
# window placed directly on top of an opening in a roof). Detect these
# fillings geometrically by matching the projected footprint of a window or
# door with the opening it occupies.
geometric_fillings: dict[int, ifcopenshell.entity_instance] = {}
filling_candidates = []
for element in building_elements:
if element.is_a() not in ("IfcWindow", "IfcDoor") or element.id() in all_filling_ids:
continue
es = shapes[element.id()]
world = sb.np_apply_matrix(es["verts"], es["matrix"])
filling_candidates.append(
(
element,
shapely.box(world[:, 0].min(), world[:, 1].min(), world[:, 0].max(), world[:, 1].max()),
float(world[:, 2].min()),
float(world[:, 2].max()),
)
)
if filling_candidates:
settings = ifcopenshell.geom.settings()
for element in building_elements:
for rel in getattr(element, "HasOpenings", []):
opening = rel.RelatedOpeningElement
if opening.HasFillings:
continue
try:
o_shape = ifcopenshell.geom.create_shape(settings, opening)
except Exception:
continue
o_verts = ifcopenshell.util.shape.get_vertices(o_shape.geometry)
o_matrix = ifcopenshell.util.shape.get_shape_matrix(o_shape)
o_world = sb.np_apply_matrix(o_verts, o_matrix)
o_xy_box = shapely.box(
o_world[:, 0].min(), o_world[:, 1].min(), o_world[:, 0].max(), o_world[:, 1].max()
)
o_zmin, o_zmax = float(o_world[:, 2].min()), float(o_world[:, 2].max())
best_filling = None
best_overlap = 0.0
for candidate, c_xy_box, c_zmin, c_zmax in filling_candidates:
overlap = o_xy_box.intersection(c_xy_box).area
if overlap < 0.8 * min(o_xy_box.area, c_xy_box.area):
continue
if max(o_zmin, c_zmin) - min(o_zmax, c_zmax) > 0.1:
continue
if overlap > best_overlap:
best_overlap = overlap
best_filling = candidate
if best_filling is not None:
geometric_fillings[opening.id()] = best_filling
all_filling_ids.add(best_filling.id())
processed_fillings: set[int] = set()
matched_element_ids: set[int] = set()
matched_walls_and_columns: set[int] = set()
space_centroid_world = sb.np_apply_matrix(np.mean(space_verts_local, axis=0)[np.newaxis], space_matrix)[0]
# Per-face data used to detect and fill gaps so that generated boundaries
# form a water-tight enclosure.
space_face_polygons = {}
face_matrices = {}
face_matrix_invs = {}
space_face_normals_world = {}
covered_by_face = {}
for space_ngon_idx, space_ngon in enumerate(space_ngons):
space_verts_l = space_verts_local[space_ngon]
space_face_normal_local = _face_normal(space_verts_l)
if space_face_normal_local is None:
continue
space_face_normal_local = _ensure_outward(
space_face_normal_local, space_verts_l, space_centroid_world, space_matrix
)
space_face_normal_world = space_matrix_3x3 @ space_face_normal_local
face_matrix = _face_matrix_from_verts(space_verts_l)
face_matrix_inv = np.linalg.inv(face_matrix)
space_face_polygon = _verts_to_polygon(space_verts_l, face_matrix_inv, snap=1e-6)
if not space_face_polygon.is_valid:
space_face_polygon = space_face_polygon.buffer(0)
space_face_polygon = _union_coplanar_face_polygon(
space_verts_local,
space_shape["faces"],
space_triangle_normals,
space_verts_l[0],
space_face_normal_local,
face_matrix_inv,
space_face_polygon,
)
space_face_polygons[space_ngon_idx] = space_face_polygon
face_matrices[space_ngon_idx] = face_matrix
face_matrix_invs[space_ngon_idx] = face_matrix_inv
space_face_normals_world[space_ngon_idx] = space_face_normal_world
covered_by_face[space_ngon_idx] = []
candidates = []
for element in building_elements:
if element.id() in all_filling_ids:
continue
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
continue
match = _match_element_to_space_face(
element,
shapes,
element_ngons,
space_matrix,
space_matrix_inv,
space_verts_l,
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
)
if match is None:
continue
dist_min, plane_offset_min, matching_polygons, matched_elem_normal = match
if len(matching_polygons) == 1:
gross_boundary_polygon = matching_polygons[0]
else:
gross_boundary_polygon = shapely.ops.unary_union(matching_polygons)
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid):
continue
if gross_boundary_polygon.is_empty:
continue
candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
# A space face may be matched by several elements within the distance
# tolerance (e.g. a second wall layer or an element end cap). The
# element closest to the face is the actual bounding surface, so
# candidates are kept in order of increasing plane offset (ties broken
# by polygon area). A candidate whose polygon is entirely covered by
# the candidates already kept is redundant and is absorbed into the
# larger boundary. This includes coplanar candidates: e.g. wall end
# caps that are coplanar with the ceiling and fully covered by the
# slab above do not get their own boundary in the reference output.
surviving_candidates = []
kept_union = None
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in sorted(
candidates, key=lambda c: (c[2] if c[2] is not None else float("inf"), -c[3].area)
):
if kept_union is not None and gross_boundary_polygon.difference(kept_union).area < 1e-4:
continue
surviving_candidates.append(
(element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
)
kept_union = gross_boundary_polygon if kept_union is None else kept_union.union(gross_boundary_polygon)
# When a single element bounds the space face and its face is (nearly)
# coplanar with it, the boundary covers the full space face (1st level
# semantics) rather than the clipped intersection with the element
# face. This matches the reference output and avoids leaving corner
# slivers to be filled by an extra gap boundary.
if len(surviving_candidates) == 1:
element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal = surviving_candidates[0]
if plane_offset_min is not None and plane_offset_min <= FULL_FACE_OFFSET_TOL:
gross_boundary_polygon = space_face_polygon
surviving_candidates[0] = (element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in surviving_candidates:
exterior_boundary_polygon = gross_boundary_polygon
opening_source_element = element
for rel in getattr(element, "Decomposes", []):
if rel.RelatingObject.is_a() in BOUNDARY_ELEMENT_CLASSES:
element = rel.RelatingObject
break
# The gross boundary polygon may still carry the openings of the
# building element (e.g. when the authoring tool baked them into the
# element geometry). An inner boundary is supposed to overlap its
# parent boundary according to IFC4 documentation, so the openings
# are unioned back into the parent to keep it hole-free while the
# filling gets its own parented boundary.
openings_to_process = []
for rel in getattr(opening_source_element, "HasOpenings", []):
opening = rel.RelatedOpeningElement
filling = (
opening.HasFillings[0].RelatedBuildingElement
if opening.HasFillings
else geometric_fillings.get(opening.id())
)
if filling is None:
continue
opening_polygon = _compute_opening_polygon(
ifc_file, opening, matched_elem_normal, space_matrix_inv, face_matrix_inv
)
if opening_polygon is None:
continue
if opening_polygon.intersection(gross_boundary_polygon).area == 0:
continue
openings_to_process.append((opening, filling, opening_polygon))
exterior_boundary_polygon = _union_openings_into_parent(exterior_boundary_polygon, openings_to_process)
exterior_boundary_polygon = exterior_boundary_polygon.simplify(1e-5)
if isinstance(exterior_boundary_polygon, shapely.Polygon) and not exterior_boundary_polygon.is_empty:
ext_coords = [
(round(x / 1e-8) * 1e-8, round(y / 1e-8) * 1e-8)
for x, y in exterior_boundary_polygon.exterior.coords
]
int_coords = [
[(round(x / 1e-8) * 1e-8, round(y / 1e-8) * 1e-8) for x, y in interior.coords]
for interior in exterior_boundary_polygon.interiors
]
snapped = shapely.Polygon(ext_coords, int_coords)
if not snapped.is_empty:
cleaned = snapped.buffer(0).simplify(1e-5)
if isinstance(cleaned, shapely.Polygon) and not cleaned.is_empty:
exterior_boundary_polygon = cleaned
matched_walls_and_columns.add(element.id())
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
if element.is_a("IfcVirtualElement"):
parent_boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
else:
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
_set_internal_external(parent_boundary, element)
parent_boundary.RelatingSpace = space
parent_boundary.RelatedBuildingElement = element
_assign_connection_geometry(
ifc_file,
parent_boundary,
exterior_boundary_polygon,
face_matrix,
unit_scale,
)
_set_boundary_name(parent_boundary)
boundaries.append(parent_boundary)
covered_by_face[space_ngon_idx].append(exterior_boundary_polygon)
boundaries.extend(
_process_openings(
ifc_file,
openings_to_process,
face_matrix,
boundary_class,
parent_boundary,
space,
unit_scale,
processed_fillings,
covered_by_face[space_ngon_idx],
)
)
boundaries.extend(
_fill_face_gaps(
ifc_file,
space,
boundary_class,
unit_scale,
space_face_polygons,
face_matrices,
face_matrix_invs,
space_face_normals_world,
space_verts_local,
space_ngons,
space_matrix,
space_matrix_inv,
shapes,
element_ngons,
covered_by_face,
building_elements,
all_filling_ids,
matched_walls_and_columns,
)
)
return boundaries
def _match_element_to_space_face(
element,
shapes,
element_ngons,
space_matrix,
space_matrix_inv,
space_verts_l,
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
):
"""Match a building element's faces against a single space face.
:return: A tuple ``(dist_min, matching_polygons, matched_elem_normal)`` with
the minimum face distance, the matching boundary polygons and the matched
face normal in world space, or ``None`` when the element does not bound
this space face.
"""
element_shape = shapes[element.id()]
element_matrix = element_shape["matrix"]
element_matrix_3x3 = element_matrix[:3, :3]
element_matrix_inv = np.linalg.inv(element_matrix)
element_centroid_world = sb.np_apply_matrix(np.mean(element_shape["verts"], axis=0)[np.newaxis], element_matrix)[0]
space_centroid = np.mean(space_verts_l, axis=0)
matching_polygons = []
matched_elem_normal = None
dist_min = None
plane_offset_min = None
for ngon in element_ngons[element.id()]:
elem_verts_l = element_shape["verts"][ngon]
elem_face_normal_local = _face_normal(elem_verts_l)
if elem_face_normal_local is None:
continue
elem_face_normal_local = _ensure_outward(
elem_face_normal_local, elem_verts_l, element_centroid_world, element_matrix
)
elem_face_normal_world = element_matrix_3x3 @ elem_face_normal_local
angle = degrees(acos(max(min(float(np.dot(space_face_normal_world, elem_face_normal_world)), 1), -1)))
is_horizontal_face = abs(space_face_normal_world[2]) > 0.5 and abs(elem_face_normal_world[2]) > 0.5
is_valid_element = (
element.is_a("IfcVirtualElement")
or element.is_a("IfcSlab")
or element.is_a("IfcWindow")
or element.is_a("IfcDoor")
)
is_anti_parallel = _is_x(angle, 180, tolerance=2)
is_parallel = _is_x(angle, 0, tolerance=2)
if not (is_anti_parallel or (is_horizontal_face and is_parallel and is_valid_element)):
continue
sv_in_elem = sb.np_apply_matrix(space_centroid[np.newaxis], element_matrix_inv @ space_matrix)[0]
dist = float(np.dot(sv_in_elem - elem_verts_l[0], elem_face_normal_local))
dist_tol = 0.05 if is_horizontal_face else 0.5
if abs(dist) > dist_tol:
continue
elem_verts_in_space = sb.np_apply_matrix(elem_verts_l, space_matrix_inv @ element_matrix)
face_polygon = _verts_to_polygon(elem_verts_in_space, face_matrix_inv, snap=1e-6)
if not face_polygon.is_valid:
face_polygon = face_polygon.buffer(0)
try:
gross_boundary_polygon = space_face_polygon.intersection(face_polygon)
except shapely.errors.GEOSException:
logger.warning(
"Skipping invalid geometry for %s (shapely topology error).",
element.Name or element.is_a(),
exc_info=True,
)
continue
if gross_boundary_polygon.is_empty or gross_boundary_polygon.area < 1e-4:
continue
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid):
continue
if gross_boundary_polygon.is_empty:
continue
matching_polygons.append(gross_boundary_polygon)
matched_elem_normal = elem_face_normal_world
dist_min = abs(dist) if dist_min is None else min(dist_min, abs(dist))
space_face_normal = _face_normal(space_verts_l)
if space_face_normal is not None:
plane_offset = abs(float(np.dot(space_face_normal, elem_verts_in_space[0] - space_verts_l[0])))
plane_offset_min = plane_offset if plane_offset_min is None else min(plane_offset_min, plane_offset)
if not matching_polygons:
return None
return dist_min, plane_offset_min, matching_polygons, matched_elem_normal
def _union_openings_into_parent(exterior_boundary_polygon, openings_to_process):
"""Union the opening polygons back into the parent boundary polygon.
Authoring tools may bake openings into the building element mesh, so the
parent boundary polygon can be notched where the opening is. Since an inner
boundary is supposed to overlap its parent boundary, the openings are
unioned back into the parent while the filling gets its own boundary.
"""
for _, _, opening_polygon in openings_to_process:
unionised_object = exterior_boundary_polygon.union(opening_polygon)
if isinstance(unionised_object, shapely.Polygon):
exterior_boundary_polygon = unionised_object
return exterior_boundary_polygon
def _compute_opening_polygon(ifc_file, opening, face_normal_world, space_matrix_inv, face_matrix_inv):
"""Project an opening onto the building element face in space-local coordinates.
:param opening: The IfcOpeningElement to project.
:param face_normal_world: The building element face normal in world space.
:param space_matrix_inv: Inverse of the space placement matrix.
:param face_matrix_inv: The inverse face matrix (for 2D projection).
:return: A 2D shapely polygon in space-local coordinates, or None.
"""
settings = ifcopenshell.geom.settings()
try:
shape = ifcopenshell.geom.create_shape(settings, opening)
except Exception:
return None
opening_verts_l = ifcopenshell.util.shape.get_vertices(shape.geometry)
opening_faces = ifcopenshell.util.shape.get_faces(shape.geometry)
opening_edges = ifcopenshell.util.shape.get_edges(shape.geometry)
opening_matrix = ifcopenshell.util.shape.get_shape_matrix(shape)
opening_matrix_3x3 = opening_matrix[:3, :3]
opening_ngons = ifcopenshell.util.shape.dissolve_faces(
opening_verts_l, opening_faces, opening_edges, merge_coplanar=True
)
opening_polygons = []
for ngon in opening_ngons:
o_verts_l = opening_verts_l[ngon]
o_normal_local = _face_normal(o_verts_l)
if o_normal_local is None:
continue
o_normal_world = opening_matrix_3x3 @ o_normal_local
angle = degrees(acos(max(min(float(np.dot(o_normal_world, face_normal_world)), 1), -1)))
if not _is_x(angle, 180, tolerance=2):
continue
o_verts_in_space = sb.np_apply_matrix(o_verts_l, space_matrix_inv @ opening_matrix)
polygon = _verts_to_polygon(o_verts_in_space, face_matrix_inv)
opening_polygons.append(polygon)
if not opening_polygons:
return None
return shapely.ops.unary_union(opening_polygons)
def _process_openings(
ifc_file,
openings_to_process,
face_matrix,
boundary_class,
parent_boundary,
space,
unit_scale,
processed_fillings: set[int],
covered_polygons: list,
):
"""Create boundaries for the fillings of openings in a building element.
:param openings_to_process: Tuples of (opening, filling, opening polygon).
:param face_matrix: The face matrix in space-local coordinates (for connection geometry).
:param processed_fillings: Set of element IDs that already have opening boundaries.
:param covered_polygons: Accumulated boundary polygons used for water-tightness checks.
"""
boundaries = []
for opening, filling, opening_polygon in openings_to_process:
filling_id = filling.id()
if filling_id in processed_fillings:
continue
boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
boundary.RelatingSpace = space
boundary.RelatedBuildingElement = filling or opening
# Use the same space-local face_matrix for connection geometry
_assign_connection_geometry(
ifc_file,
boundary,
opening_polygon,
face_matrix,
unit_scale,
)
if filling:
boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
else:
boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
boundary.InternalOrExternalBoundary = parent_boundary.InternalOrExternalBoundary
if boundary.is_a() != "IfcRelSpaceBoundary":
boundary.ParentBoundary = parent_boundary
_set_boundary_name(boundary)
processed_fillings.add(filling_id)
covered_polygons.append(opening_polygon)
boundaries.append(boundary)
return boundaries
def _fill_face_gaps(
ifc_file,
space,
boundary_class,
unit_scale,
space_face_polygons,
face_matrices,
face_matrix_invs,
space_face_normals_world,
space_verts_local,
space_ngons,
space_matrix,
space_matrix_inv,
shapes,
element_ngons,
covered_by_face,
building_elements,
all_filling_ids,
matched_walls_and_columns,
):
"""Create boundaries for uncovered parts of space faces to keep them water tight."""
boundaries = []
for face_idx, space_face_polygon in space_face_polygons.items():
covered_polygons = covered_by_face.get(face_idx, [])
if not covered_polygons:
continue
uncovered = space_face_polygon.difference(shapely.ops.unary_union(covered_polygons))
if uncovered.is_empty:
continue
if isinstance(uncovered, shapely.Polygon):
fragments = [uncovered]
elif isinstance(uncovered, shapely.MultiPolygon):
fragments = list(uncovered.geoms)
else:
continue
for fragment in fragments:
if fragment.area < 1e-2:
continue
element = _best_element_for_gap(
fragment,
space_verts_local[space_ngons[face_idx]],
space_matrix,
space_matrix_inv,
face_matrices[face_idx],
face_matrix_invs[face_idx],
space_face_normals_world[face_idx],
shapes,
element_ngons,
building_elements,
all_filling_ids,
matched_walls_and_columns,
)
if element is None:
logger.warning(
"No element found to fill a gap on a face of space %s.",
space.Name or space.is_a(),
)
continue
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
_set_internal_external(parent_boundary, element)
parent_boundary.RelatingSpace = space
parent_boundary.RelatedBuildingElement = element
_assign_connection_geometry(
ifc_file,
parent_boundary,
fragment,
face_matrices[face_idx],
unit_scale,
)
_set_boundary_name(parent_boundary)
boundaries.append(parent_boundary)
return boundaries
def _best_element_for_gap(
fragment,
space_face_verts,
space_matrix,
space_matrix_inv,
face_matrix,
face_matrix_inv,
space_face_normal_world,
shapes,
element_ngons,
building_elements,
all_filling_ids,
matched_walls_and_columns,
):
"""Find the element most appropriate to cover an uncovered part of a space face."""
best = None
best_overlap = 0.0
space_centroid = np.mean(space_face_verts, axis=0)
for element in building_elements:
if element.id() in all_filling_ids:
continue
# Walls and columns already bounding this space keep a single boundary;
# a gap is therefore filled by a neighbouring element instead.
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
continue
es = shapes[element.id()]
e_matrix = es["matrix"]
e_matrix_3x3 = e_matrix[:3, :3]
e_matrix_inv = np.linalg.inv(e_matrix)
e_centroid_world = sb.np_apply_matrix(np.mean(es["verts"], axis=0)[np.newaxis], e_matrix)[0]
for ngon in element_ngons[element.id()]:
elem_verts_l = es["verts"][ngon]
normal_local = _face_normal(elem_verts_l)
if normal_local is None:
continue
normal_local = _ensure_outward(normal_local, elem_verts_l, e_centroid_world, e_matrix)
normal_world = e_matrix_3x3 @ normal_local
angle = degrees(acos(max(min(float(np.dot(space_face_normal_world, normal_world)), 1), -1)))
is_horizontal_face = abs(space_face_normal_world[2]) > 0.5 and abs(normal_world[2]) > 0.5
is_valid_element = (
element.is_a("IfcVirtualElement")
or element.is_a("IfcSlab")
or element.is_a("IfcWindow")
or element.is_a("IfcDoor")
)
if not (
_is_x(angle, 180, tolerance=2)
or (is_horizontal_face and _is_x(angle, 0, tolerance=2) and is_valid_element)
):
continue
sv_in_elem = sb.np_apply_matrix(space_centroid[np.newaxis], e_matrix_inv @ space_matrix)[0]
dist = float(np.dot(sv_in_elem - elem_verts_l[0], normal_local))
dist_tol = 0.05 if is_horizontal_face else 0.5
if abs(dist) > dist_tol:
continue
elem_verts_in_space = sb.np_apply_matrix(elem_verts_l, space_matrix_inv @ e_matrix)
face_polygon = _verts_to_polygon(elem_verts_in_space, face_matrix_inv, snap=1e-6)
if not face_polygon.is_valid:
face_polygon = face_polygon.buffer(0)
overlap = fragment.intersection(face_polygon).area
if overlap > best_overlap:
best_overlap = overlap
best = element
if best is not None:
return best
# For gaps at corners between non-parallel faces, fall back to the element
# whose plan footprint covers the gap centroid.
frag_2d = np.array([[c[0], c[1], 0.0] for c in fragment.exterior.coords])
frag_local = sb.np_apply_matrix(frag_2d, face_matrix)
frag_world = sb.np_apply_matrix(frag_local, space_matrix)
frag_centroid = frag_world.mean(axis=0)
is_horizontal_face = abs(space_face_normal_world[2]) > 0.5
best = None
best_dist = np.inf
for element in building_elements:
if element.id() in all_filling_ids:
continue
if is_horizontal_face:
if not (element.is_a("IfcSlab") or element.is_a("IfcRoof") or element.is_a("IfcVirtualElement")):
continue
elif not (element.is_a("IfcWall") or element.is_a("IfcColumn") or element.is_a("IfcVirtualElement")):
continue
es = shapes[element.id()]
world = sb.np_apply_matrix(es["verts"], es["matrix"])
elem_xy = shapely.box(world[:, 0].min(), world[:, 1].min(), world[:, 0].max(), world[:, 1].max())
if not elem_xy.contains(shapely.Point(frag_centroid[:2])):
continue
elem_centroid = world.mean(axis=0)
dist = float(np.linalg.norm(elem_centroid - frag_centroid))
if dist < best_dist:
best_dist = dist
best = element
return best
def _face_normal(verts: np.ndarray) -> Optional[np.ndarray]:
"""Compute the normal of a polygon from its vertices."""
if len(verts) < 3:
return None
for i in range(len(verts) - 2):
v0, v1, v2 = verts[i], verts[i + 1], verts[i + 2]
cross = np.cross(v1 - v0, v2 - v0)
norm = np.linalg.norm(cross)
if norm > 1e-8:
return cross / norm
return None
def _face_matrix_from_verts(verts3: np.ndarray) -> np.ndarray:
"""Build a 4x4 face-local coordinate matrix from a polygon's vertices.
The first three vertices may be collinear in triangulated meshes, so the
normal is computed from the first non-degenerate triple and the X axis is
taken from the first non-degenerate edge.
"""
p1 = np.asarray(verts3[0])
normal = _face_normal(verts3)
if normal is None:
raise ValueError("Cannot build face matrix from a degenerate polygon")
x = np.zeros(3)
for i in range(len(verts3) - 1):
edge = np.asarray(verts3[i + 1]) - np.asarray(verts3[i])
edge_norm = np.linalg.norm(edge)
if edge_norm > 1e-8:
x = edge / edge_norm
break
if np.linalg.norm(x) < 1e-8:
raise ValueError("Cannot find a non-degenerate edge for the face matrix")
return ifcopenshell.util.placement.a2p(o=p1, z=normal, x=x)
def _verts_to_polygon(verts: np.ndarray, face_matrix_inv: np.ndarray, snap: float = 0) -> shapely.Polygon:
"""Project 3D vertices onto a 2D plane and create a shapely Polygon."""
verts_2d = sb.np_apply_matrix(verts, face_matrix_inv)[:, :2]
if snap:
verts_2d = np.round(verts_2d / snap) * snap
return shapely.Polygon([tuple(v) for v in verts_2d])
def _assign_connection_geometry(
ifc_file: ifcopenshell.file,
boundary: ifcopenshell.entity_instance,
polygon: shapely.Polygon,
face_matrix: np.ndarray,
unit_scale: float,
) -> None:
"""Assign connection geometry to a boundary using the existing API."""
location = face_matrix[:3, 3]
axis = face_matrix[:3, 2]
ref_direction = face_matrix[:3, 0]
outer_boundary = [list(coord) for coord in polygon.exterior.coords[:-1]]
inner_boundaries = [list(interior.coords[:-1]) for interior in polygon.interiors]
ifcopenshell.api.boundary.assign_connection_geometry(
ifc_file,
rel_space_boundary=boundary,
outer_boundary=outer_boundary,
location=location.tolist(),
axis=axis.tolist(),
ref_direction=ref_direction.tolist(),
inner_boundaries=inner_boundaries if inner_boundaries else None,
unit_scale=unit_scale,
)
def _set_internal_external(
boundary: ifcopenshell.entity_instance, building_element: ifcopenshell.entity_instance
) -> None:
"""Set InternalOrExternalBoundary based on element type and psets."""
if building_element.is_a("IfcWall"):
is_external = ifcopenshell.util.element.get_pset(building_element, "Pset_WallCommon", "IsExternal")
if is_external is True:
boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
boundary.InternalOrExternalBoundary = "INTERNAL"
elif building_element.is_a("IfcSlab"):
predefined_type = ifcopenshell.util.element.get_predefined_type(building_element)
if predefined_type == "BASESLAB":
boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH"
else:
is_external = ifcopenshell.util.element.get_pset(building_element, "Pset_SlabCommon", "IsExternal")
if is_external is True:
boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
boundary.InternalOrExternalBoundary = "INTERNAL"
def _set_boundary_name(boundary: ifcopenshell.entity_instance) -> None:
"""Set Name/Description per IFC4x3 convention."""
if boundary.is_a("IfcRelSpaceBoundary2ndLevel"):
boundary.Name = "2ndLevel"
if boundary.CorrespondingBoundary:
boundary.Description = "2a"
else:
boundary.Description = "2b"
elif boundary.is_a("IfcRelSpaceBoundary1stLevel"):
boundary.Name = "1stLevel"
def _ensure_outward(
normal_local: np.ndarray,
face_verts_l: np.ndarray,
entity_centroid_world: np.ndarray,
entity_matrix: np.ndarray,
) -> np.ndarray:
"""Flip face normal to point away from the entity centroid."""
face_centroid_world = sb.np_apply_matrix(np.mean(face_verts_l, axis=0)[np.newaxis], entity_matrix)[0]
normal_world = entity_matrix[:3, :3] @ normal_local
if np.dot(face_centroid_world - entity_centroid_world, normal_world) < 0:
return -normal_local
return normal_local
def _is_x(value: float, x: float, tolerance: float = 1e-5) -> bool:
"""Check whether value is within tolerance of x."""
return (x + tolerance) > value > (x - tolerance)
@@ -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,279 +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
def dissolve_faces(
verts: npt.NDArray[np.float64],
faces: npt.NDArray[np.int32],
edges: npt.NDArray[np.int32],
merge_coplanar: bool = False,
angle_tolerance: float = 0.017453292519943295,
) -> list[list[int]]:
"""Reconstruct polygonal faces from triangulated mesh data.
Uses the original (pre-triangulation) edges from ``get_edges`` to
identify which triangle edges are internal (to be merged) vs external
(ngon boundaries). Triangles connected by internal edges are grouped
into polygonal faces.
When ``merge_coplanar`` is True, a second pass merges adjacent ngons
whose face normals are parallel within ``angle_tolerance`` radians.
This mirrors ``bmesh.ops.dissolve_limit`` behavior where coplanar
faces sharing an edge are merged regardless of the original face
structure. This is needed when the IFC representation splits a single
planar face into multiple faces (e.g. an L-shaped top face split into
triangles + quads).
:param verts: (n, 3) array of vertices.
:param faces: (m, 3) array of triangle vertex indices.
:param edges: (e, 2) array of original (pre-triangulation) edge vertex
indices, as returned by :func:`get_edges`.
:param merge_coplanar: If True, merge adjacent coplanar ngons.
:param angle_tolerance: Angle in radians for coplanar merge (default 1°).
:return: List of polygonal faces, each as an ordered list of vertex indices
forming a closed polygon (last vertex connects back to first).
"""
if len(faces) == 0:
return []
if len(edges) == 0:
return [list(f) for f in faces]
original_edges = {frozenset((int(e[0]), int(e[1]))) for e in edges}
tri_edges = []
for f in faces:
tri_edges.append(
(
frozenset((int(f[0]), int(f[1]))),
frozenset((int(f[1]), int(f[2]))),
frozenset((int(f[2]), int(f[0]))),
)
)
internal_edge_to_tris: dict[frozenset, list[int]] = {}
for tri_idx, edges_3 in enumerate(tri_edges):
for e in edges_3:
if e not in original_edges:
internal_edge_to_tris.setdefault(e, []).append(tri_idx)
parent = list(range(len(faces)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px != py:
parent[px] = py
for tri_indices in internal_edge_to_tris.values():
if len(tri_indices) == 2:
union(tri_indices[0], tri_indices[1])
ngons: dict[int, list[int]] = {}
for tri_idx in range(len(faces)):
root = find(tri_idx)
ngons.setdefault(root, []).append(tri_idx)
if merge_coplanar:
_merge_coplanar_ngons(ngons, faces, verts, tri_edges, parent, find, union, angle_tolerance)
result = []
for tri_indices in ngons.values():
tri_edge_set = set()
edge_count: dict[frozenset, int] = {}
for tri_idx in tri_indices:
for e in tri_edges[tri_idx]:
tri_edge_set.add(e)
edge_count[e] = edge_count.get(e, 0) + 1
if merge_coplanar:
boundary_edges = [e for e in tri_edge_set if edge_count.get(e, 0) == 1]
else:
boundary_edges = [e for e in tri_edge_set if e in original_edges]
if not boundary_edges:
result.append(list(faces[tri_indices[0]]))
continue
edge_adjacency: dict[int, int] = {}
for e in boundary_edges:
v_list = list(e)
for tri_idx in tri_indices:
f = faces[tri_idx]
f_edges = [(int(f[0]), int(f[1])), (int(f[1]), int(f[2])), (int(f[2]), int(f[0]))]
for fe in f_edges:
if frozenset(fe) == e:
edge_adjacency[fe[0]] = fe[1]
break
else:
continue
break
if not edge_adjacency:
result.append(list(faces[tri_indices[0]]))
continue
start = next(iter(edge_adjacency))
polygon = [start]
current = edge_adjacency[start]
while current != start and current in edge_adjacency:
polygon.append(current)
current = edge_adjacency[current]
if len(polygon) >= 3:
result.append(polygon)
else:
result.append(list(faces[tri_indices[0]]))
return result
def _merge_coplanar_ngons(
ngons: dict[int, list[int]],
faces: npt.NDArray[np.int32],
verts: npt.NDArray[np.float64],
tri_edges: list,
parent: list[int],
find,
union,
angle_tolerance: float,
) -> None:
"""Merge adjacent ngons whose face normals are parallel within tolerance.
Modifies ``ngons`` and ``parent`` in place.
"""
from math import acos
# Compute normal for each ngon
ngon_normals: dict[int, np.ndarray] = {}
ngon_edge_to_ngons: dict[frozenset, list[int]] = {}
ngon_roots = list(ngons.keys())
for root in ngon_roots:
tri_indices = ngons[root]
f0 = faces[tri_indices[0]]
v0, v1, v2 = verts[f0[0]], verts[f0[1]], verts[f0[2]]
edge1 = v1 - v0
edge2 = v2 - v0
normal = np.cross(edge1, edge2)
norm = np.linalg.norm(normal)
if norm > 1e-8:
normal = normal / norm
ngon_normals[root] = normal
# Collect all edges of this ngon
ngon_edges = set()
for tri_idx in tri_indices:
for e in tri_edges[tri_idx]:
ngon_edges.add(e)
for e in ngon_edges:
ngon_edge_to_ngons.setdefault(e, []).append(root)
# Find shared edges between different ngons and check coplanarity
for edge, root_list in ngon_edge_to_ngons.items():
if len(root_list) != 2:
continue
root_a, root_b = root_list[0], root_list[1]
if root_a == root_b:
continue
# Check if already merged
ra, rb = find(root_a), find(root_b)
if ra == rb:
continue
# Compare normals
na, nb = ngon_normals[root_a], ngon_normals[root_b]
dot = max(min(float(np.dot(na, nb)), 1.0), -1.0)
angle = acos(dot)
if angle < angle_tolerance:
union(root_a, root_b)
# Rebuild ngons dict with merged groups
new_ngons: dict[int, list[int]] = {}
for root in ngon_roots:
new_root = find(root)
new_ngons.setdefault(new_root, []).extend(ngons[root])
ngons.clear()
ngons.update(new_ngons)
@@ -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()
@@ -1,403 +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 os
from collections import Counter
import numpy as np
import pytest
import shapely
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.util.boundary as subject
import ifcopenshell.util.shape
import test.bootstrap
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)
def _build_shapes_dict(ifc_file, elements):
"""Build a shapes dict as expected by ifcopenshell.util.boundary."""
settings = ifcopenshell.geom.settings()
settings.set("disable-opening-subtractions", True)
shapes = {}
for element in elements:
shape = ifcopenshell.geom.create_shape(settings, element)
shapes[element.id()] = {
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
}
return shapes
def _build_shapes_dict_from_iterator(ifc_file):
"""Build a shapes dict for all products in a file (excluding openings)."""
settings = ifcopenshell.geom.settings()
settings.set("disable-opening-subtractions", True)
shapes = {}
iterator = ifcopenshell.geom.iterator(settings, ifc_file)
if iterator.initialize():
while True:
shape = iterator.get()
element = ifc_file.by_id(shape.id)
if not element.is_a("IfcOpeningElement"):
shapes[shape.id] = {
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
}
if not iterator.next():
break
return shapes
def _boundaries_for(boundaries, element):
return [b for b in boundaries if b.RelatedBuildingElement == element]
def _boundary_inner_count(boundary):
surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement if boundary.ConnectionGeometry else None
if surface and surface.is_a("IfcCurveBoundedPlane") and surface.InnerBoundaries:
return len(surface.InnerBoundaries)
return 0
def _outer_boundary_area(boundary):
surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement
points = [(p.Coordinates[0], p.Coordinates[1]) for p in surface.OuterBoundary.Points]
area = 0.0
for (x1, y1), (x2, y2) in zip(points, points[1:]):
area += x1 * y2 - x2 * y1
return 0.5 * abs(area)
def _boundary_polygon_3d(boundary):
"""The boundary outer boundary as world-space 3D points."""
surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement
position = surface.BasisSurface.Position
origin = np.array(position.Location.Coordinates, dtype=float)
z = np.array(position.Axis.DirectionRatios if position.Axis else [0, 0, 1], dtype=float)
x = np.array(position.RefDirection.DirectionRatios if position.RefDirection else [1, 0, 0], dtype=float)
y = np.cross(z, x)
points = np.array([[p.Coordinates[0], p.Coordinates[1]] for p in surface.OuterBoundary.Points])
return origin + points[:, 0, None] * x + points[:, 1, None] * y
def _boundary_polygon_in_plane(boundary, reference=None):
"""The boundary polygon projected onto the reference boundary plane."""
reference = reference or boundary
surface = reference.ConnectionGeometry.SurfaceOnRelatingElement
position = surface.BasisSurface.Position
origin = np.array(position.Location.Coordinates, dtype=float)
z = np.array(position.Axis.DirectionRatios if position.Axis else [0, 0, 1], dtype=float)
x = np.array(position.RefDirection.DirectionRatios if position.RefDirection else [1, 0, 0], dtype=float)
y = np.cross(z, x)
points = _boundary_polygon_3d(boundary) - origin
coords = [(float(p @ x), float(p @ y)) for p in points]
return shapely.Polygon(coords)
def _add_wall_with_window(ifc_file):
"""Add a space bounded by a wall with a fully interior opening filled by a window."""
space = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcSpace")
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall")
opening_element = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcOpeningElement")
window = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWindow")
_add_extruded_body(ifc_file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(ifc_file, wall, [[-5, 5], [5, 5], [5, 5.5], [-5, 5.5]], 3.0)
_add_extruded_body(ifc_file, opening_element, [[-2, 5], [2, 5], [2, 5.5], [-2, 5.5]], 1.8, z_offset=0.6)
_add_extruded_body(ifc_file, window, [[-2, 4.5], [2, 4.5], [2, 5.5], [-2, 5.5]], 1.5, z_offset=0.75)
ifc_file.createIfcRelVoidsElement(RelatingBuildingElement=wall, RelatedOpeningElement=opening_element)
ifc_file.createIfcRelFillsElement(RelatingOpeningElement=opening_element, RelatedBuildingElement=window)
return space, wall, window
def _add_roof_with_skylight(ifc_file):
"""Add a space with a roof pierced by an opening covered by a skylight window.
The window is deliberately not related through IfcRelFillsElement to exercise
the geometric detection of fillings.
"""
space = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcSpace")
roof = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcRoof")
opening_element = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcOpeningElement")
window = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWindow")
_add_extruded_body(ifc_file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(ifc_file, roof, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.5, z_offset=3.0)
_add_extruded_body(ifc_file, opening_element, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 0.8, z_offset=2.8)
_add_extruded_body(ifc_file, window, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 0.2, z_offset=3.5)
ifc_file.createIfcRelVoidsElement(RelatingBuildingElement=roof, RelatedOpeningElement=opening_element)
return space, roof, window
def _external_earth_ifczip():
return os.path.join(
os.path.dirname(__file__),
"..",
"IfcRelSpaceBoundary_TestFiles",
"IfcRelSpaceBoundary2ndLevel",
"ExternalEarth_R20_IFC4.ifczip",
)
def _over_splitted_ifc():
return os.path.join(
os.path.dirname(__file__),
"..",
"IfcRelSpaceBoundary_TestFiles",
"IfcRelSpaceBoundary2ndLevel",
"OverSplitted_R20_IFC2X3.ifc",
)
def _small_house_ifczip():
return os.path.join(
os.path.dirname(__file__),
"..",
"IfcRelSpaceBoundary_TestFiles",
"IfcRelSpaceBoundary2ndLevel",
"SmallHouse_BB_IFC4.ifczip",
)
def _triangle_ifczip():
return os.path.join(
os.path.dirname(__file__),
"..",
"IfcRelSpaceBoundary_TestFiles",
"IfcRelSpaceBoundary2ndLevel",
"Triangle_BB_IFC4.ifczip",
)
def _boundary_element_counts(ifc_file, space_id):
space = ifc_file.by_id(space_id)
counts = Counter()
for boundary in space.BoundedBy or []:
if boundary.RelatedBuildingElement:
counts[boundary.RelatedBuildingElement.id()] += 1
return counts
class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
def test_no_building_elements_returns_error(self):
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
shapes = _build_shapes_dict(self.file, [space])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
assert isinstance(result, str)
assert "No building elements" in result
def test_space_not_in_shapes_returns_error(self):
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
result = subject.auto_generate_boundaries(self.file, space, {}, "IfcRelSpaceBoundary")
assert isinstance(result, str)
assert "not found" in result.lower()
def test_generates_boundary_for_adjacent_wall(self):
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(self.file, wall, [[-5, 5], [5, 5], [5, 5.2], [-5, 5.2]], 3.0)
shapes = _build_shapes_dict(self.file, [space, wall])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
assert isinstance(result, list)
assert len(result) >= 1
boundary = result[0]
assert boundary.RelatingSpace == space
assert boundary.RelatedBuildingElement == wall
assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL"
def test_wall_boundary_with_window_has_no_inner_boundary(self):
space, wall, window = _add_wall_with_window(self.file)
shapes = _build_shapes_dict(self.file, [space, wall, window])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary2ndLevel")
assert isinstance(result, list)
wall_boundaries = _boundaries_for(result, wall)
assert len(wall_boundaries) == 1
assert _boundary_inner_count(wall_boundaries[0]) == 0
assert _outer_boundary_area(wall_boundaries[0]) == pytest.approx(30.0, abs=1e-3)
window_boundaries = _boundaries_for(result, window)
assert len(window_boundaries) == 1
assert window_boundaries[0].ParentBoundary == wall_boundaries[0]
def test_roof_boundary_with_skylight_has_no_inner_boundary(self):
space, roof, window = _add_roof_with_skylight(self.file)
shapes = _build_shapes_dict(self.file, [space, roof, window])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary2ndLevel")
assert isinstance(result, list)
roof_boundaries = _boundaries_for(result, roof)
assert len(roof_boundaries) == 1
assert _boundary_inner_count(roof_boundaries[0]) == 0
assert _outer_boundary_area(roof_boundaries[0]) == pytest.approx(100.0, abs=1e-3)
window_boundaries = _boundaries_for(result, window)
assert len(window_boundaries) == 1
assert window_boundaries[0].ParentBoundary == roof_boundaries[0]
def test_wall_boundary_with_unfilled_opening_has_no_inner_boundary(self):
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
opening_element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement")
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(self.file, wall, [[-5, 5], [5, 5], [5, 5.5], [-5, 5.5]], 3.0)
_add_extruded_body(self.file, opening_element, [[-2, 5], [2, 5], [2, 5.5], [-2, 5.5]], 1.8, z_offset=0.6)
self.file.createIfcRelVoidsElement(RelatingBuildingElement=wall, RelatedOpeningElement=opening_element)
shapes = _build_shapes_dict(self.file, [space, wall])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary2ndLevel")
assert isinstance(result, list)
wall_boundaries = _boundaries_for(result, wall)
assert len(wall_boundaries) == 1
assert _boundary_inner_count(wall_boundaries[0]) == 0
def test_openings_are_unioned_into_parent_boundary(self):
# Some authoring tools bake the opening into the building element mesh,
# leaving a notch in the gross boundary polygon. The parent boundary must
# union the opening back in so it overlaps its own inner boundary.
wall_face = shapely.Polygon([(-5, 5), (5, 5), (5, 5.5), (2, 5.5), (2, 5), (-2, 5), (-2, 5.5), (-5, 5.5)])
window = shapely.Polygon([(-2, 5), (2, 5), (2, 5.5), (-2, 5.5)])
assert wall_face.area == pytest.approx(3.0, abs=1e-9)
parent = subject._union_openings_into_parent(wall_face, [("opening", "window", window)])
assert isinstance(parent, shapely.Polygon)
assert parent.area == pytest.approx(5.0, abs=1e-9)
assert parent.contains(window)
def test_external_earth_boundaries(self):
ifczip = _external_earth_ifczip()
if not os.path.exists(ifczip):
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
ifc_file = ifcopenshell.open(ifczip)
shapes = _build_shapes_dict_from_iterator(ifc_file)
for space_id, expected_counts in [
(182, {996: 1, 1122: 1, 1235: 2, 1288: 1, 2970: 1, 3071: 1, 3140: 1, 3214: 1, 3435: 1, 3605: 1, 3719: 1}),
(440, {1122: 1, 3140: 1, 3214: 1, 3493: 1, 3605: 1, 3640: 1, 3669: 1, 3719: 1}),
(628, {3140: 1, 3838: 1, 3927: 1, 3980: 2, 4033: 1, 4086: 1, 4139: 1, 4199: 1}),
]:
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
new_space = copy.by_id(space_id)
result = subject.auto_generate_boundaries(
copy, new_space, shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
)
assert _boundary_element_counts(copy, space_id) == expected_counts
for boundary in result:
assert _boundary_inner_count(boundary) == 0
if boundary.ParentBoundary:
parent_polygon = _boundary_polygon_in_plane(boundary.ParentBoundary)
child_polygon = _boundary_polygon_in_plane(boundary, reference=boundary.ParentBoundary)
assert child_polygon.intersection(parent_polygon).area == pytest.approx(
child_polygon.area, abs=1e-2
)
if space_id in (182, 440):
roof_boundaries = _boundaries_for(result, copy.by_id(3214))
assert len(roof_boundaries) == 1
skylight = [b for b in result if b.RelatedBuildingElement.id() in (3435, 3640)]
assert len(skylight) == 1
assert skylight[0].ParentBoundary == roof_boundaries[0]
def test_over_splitted_roof_keeps_shaft_opening(self):
# Space 251 of OverSplitted_R20_IFC2X3.ifc has an L-shaped ceiling
# pierced by a shaft opening. The ceiling boundary must keep the
# opening as an inner boundary, and each shaft wall must get exactly
# one boundary instead of fragmented partial + gap boundaries.
ifc_path = _over_splitted_ifc()
if not os.path.exists(ifc_path):
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
ifc_file = ifcopenshell.open(ifc_path)
shapes = _build_shapes_dict_from_iterator(ifc_file)
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
result = subject.auto_generate_boundaries(
copy, copy.by_id(251), shapes=shapes, boundary_class="IfcRelSpaceBoundary"
)
assert isinstance(result, list)
assert len(result) == 17
roof_boundaries = _boundaries_for(result, copy.by_id(5248))
assert len(roof_boundaries) == 1
assert _boundary_inner_count(roof_boundaries[0]) == 1
surface = roof_boundaries[0].ConnectionGeometry.SurfaceOnRelatingElement
outer = [(p.Coordinates[0], p.Coordinates[1]) for p in surface.OuterBoundary.Points]
inner = [[(p.Coordinates[0], p.Coordinates[1]) for p in ib.Points] for ib in surface.InnerBoundaries]
assert shapely.Polygon(outer, inner).area == pytest.approx(9.962, abs=1e-3)
for wall_id in (5832, 5877, 5922, 5967):
assert len(_boundaries_for(result, copy.by_id(wall_id))) == 1
def test_small_house_boundaries(self):
ifc_path = _small_house_ifczip()
if not os.path.exists(ifc_path):
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
ifc_file = ifcopenshell.open(ifc_path)
shapes = _build_shapes_dict_from_iterator(ifc_file)
for space_id, expected_total in [(1692, 8), (4356, 8), (4380, 13), (6185, 1)]:
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
result = subject.auto_generate_boundaries(
copy, copy.by_id(space_id), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
)
assert len(result) == expected_total
def test_triangle_boundaries(self):
ifc_path = _triangle_ifczip()
if not os.path.exists(ifc_path):
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
ifc_file = ifcopenshell.open(ifc_path)
shapes = _build_shapes_dict_from_iterator(ifc_file)
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
result = subject.auto_generate_boundaries(
copy, copy.by_id(1573), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
)
assert len(result) == 6
@@ -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,139 +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
class TestDissolveFaces:
def test_dissolve_cube_into_ngons(self):
"""A triangulated cube (12 triangles) should dissolve into 6 quad faces."""
verts, faces = _cube_verts_faces(size=2.0)
edges = np.array(
[
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
],
dtype=np.int32,
)
ngons = subject.dissolve_faces(verts, faces, edges)
assert len(ngons) == 6
for ngon in ngons:
assert len(ngon) == 4
def test_dissolve_no_edges_returns_triangles(self):
"""With no original edges, triangles should be returned as-is."""
verts, faces = _cube_verts_faces(size=2.0)
edges = np.array([], dtype=np.int32).reshape(0, 2)
ngons = subject.dissolve_faces(verts, faces, edges)
assert len(ngons) == 12
for ngon in ngons:
assert len(ngon) == 3
def test_dissolve_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)
edges = np.array([], dtype=np.int32).reshape(0, 2)
assert subject.dissolve_faces(verts, faces, edges) == []
@@ -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
View File
@@ -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"