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
41 changed files with 319 additions and 4739 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
@@ -1,168 +0,0 @@
# Design Spec: Space Regeneration with Sloped Roofs, Walls, and Slabs
## Goal
Extend `bonsai.core.spatial.generate_space` so it produces correct `IfcSpace`
geometry for non-rectilinear envelopes:
- sloped roofs,
- sloped slabs,
- sloped walls,
- curved walls.
The existing footprint-based `IfcExtrudedAreaSolid` path is preserved for
ordinary vertical extrusions. A new hybrid path keeps the representation
parametric when possible and falls back to an `IfcFacetedBrep` only when the
boundary cannot be expressed as a clipped extrusion.
## Architecture
```
┌─────────────────────────────────────────┐
│ Existing footprint generation │
│ (get_space_polygon_from_*_objects) │
└──────────────┬────────────────────────────┘
v
┌─────────────────────────────────────────┐
│ Detect extrudability and bounding planes │
│ (pure-Python util, Blender-independent) │
└──────────────┬────────────────────────────┘
v
┌──────┴──────┐
v v
┌───────────────────┐ ┌───────────────────┐
│ Extrusion + clips │ │ B-rep fallback │
│ IfcExtrudedAreaSolid│ │ IfcFacetedBrep │
│ + IfcBooleanClippingResult│ │ (or IfcPolygonalFaceSet) │
└───────────────────┘ └───────────────────┘
```
## Prior art
- **CBIP** (Lilis et al.): constructive solid geometry approach that builds
space volumes as half-space intersections of bounding planes — the basis for
the parametric clipping path.
- **Fichter et al. 2021**: ray-tracing method for automatic boundary
generation; motivates the use of `geom.tree.select_ray` for top/bottom plane
detection.
- **Lilis et al. 2021**: semi-automatic boundary recognition; informs the
fallback to existing `boundary.auto_generate_boundaries` machinery.
- **Ying & Lee 2019**: faceting of curved walls; motivates the B-rep fallback
for curved-in-plan walls that cannot be represented as vertical extruded
profiles.
## Detection criteria
Use the parametric `IfcExtrudedAreaSolid` + `IfcBooleanClippingResult` path
when **all** are true:
1. Side walls are vertical extrusions (face normal is horizontal).
Curved-in-plan walls are allowed; their footprint is polygonized or
reconstructed as a curved profile.
2. The roof/top boundary is piecewise-planar.
3. The bottom slab/floor boundary is piecewise-planar.
4. The footprint is a single closed outer region, possibly with inner closed
regions for holes.
5. The resulting half-space intersection is non-empty and produces a single
solid.
Otherwise use the B-rep fallback.
## Parametric extrusion + clipping algorithm
1. **Build the profile**
- Outer ring from the footprint polygon → `IfcArbitraryClosedProfileDef`.
- Inner rings (holes, e.g., around columns) →
`IfcArbitraryProfileDefWithVoids`.
2. **Extrude**
- Create `IfcExtrudedAreaSolid` along local +Z, with a height large enough
to cover all bounding planes.
3. **Find top planes**
- Cast vertical rays upward from the footprint centroid and sample points
using `ifcopenshell.geom.tree.select_ray`.
- Check each hit face for planarity with
`ifcopenshell.util.shape.dissolve_faces(..., merge_coplanar=True)`.
- Group coplanar hits into distinct planes.
4. **Find bottom planes**
- Same as top, but downward.
5. **Clip**
- For each top plane: create `IfcHalfSpaceSolid` with normal pointing
upward (removed side), apply via `ifcopenshell.api.geometry.clip_solid`.
- For each bottom plane: create `IfcHalfSpaceSolid` with normal pointing
downward, apply via `clip_solid`.
6. **Output**
- `IfcExtrudedAreaSolid` wrapped in a chain of `IfcBooleanClippingResult`.
## B-rep fallback algorithm
For non-extrudable cases (sloped walls, curved roofs, etc.):
1. **Seed space**
- Create a temporary rough mesh (e.g., extruded footprint bounding box) as
a placeholder.
2. **Extract boundary faces**
- Run `ifcopenshell.util.boundary.auto_generate_boundaries` against the
seed to identify the faces of bounding elements that touch the space.
- Convert each boundary polygon from face-local back to 3D world
coordinates.
3. **Build closed shell**
- Collect the 3D boundary faces.
- Add narrow gap-closing faces if `auto_generate_boundaries` leaves
unmatched edges.
- Triangulate and produce `IfcClosedShell``IfcFacetedBrep` (or
`IfcPolygonalFaceSet` for IFC4+).
4. **Clean up**
- Assign the B-rep to the `IfcSpace` and remove the temporary seed
geometry.
## Files to touch
- `src/ifcopenshell-python/ifcopenshell/util/space.py`
- New: `detect_space_volume_strategy`
- New: `build_extruded_clipped_space`
- New: `build_brep_space`
- New helpers for ray-cast plane detection and face planarity checks.
- `src/bonsai/bonsai/tool/spatial.py`
- Extend `set_space_representation_from_polygon` to dispatch to the new
strategy.
- Extend footprint/profile creation to support inner rings for holes.
- `src/bonsai/bonsai/core/spatial.py`
- `generate_space` calls the dispatcher.
## Testing
- Add unit tests in `src/ifcopenshell-python/test/util/test_space.py` for pure
geometry helpers:
- simple shed roof,
- gable roof,
- sloped slab,
- L-shaped footprint with sloped roof,
- curved wall.
- Add Bonsai tests in `src/bonsai/test/tool/test_spatial.py` for end-to-end
`generate_space` with non-rectilinear geometry.
## Error handling
- If detection fails or half-space clipping produces an invalid result, fall
back to the B-rep path.
- If the B-rep path also fails, return an error string and leave the existing
space representation unchanged.
## Known limitations and non-goals
- **Curved (single/double-curvature) roofs and domes** are handled only via the
B-rep fallback; they are not expressible as `IfcExtrudedAreaSolid` +
`IfcBooleanClippingResult` in this design.
- The B-rep fallback produces **non-parametric** geometry: the resulting
`IfcFacetedBrep`/`IfcPolygonalFaceSet` cannot be re-edited parametrically by
the user afterwards. This is an accepted trade-off; the parametric path is
preferred whenever detection succeeds.
- The B-rep fallback depends on `boundary.auto_generate_boundaries`, so it
inherits its assumptions: bounding elements must be related to the space and
the seed volume must intersect them. Gap-closing faces may produce
non-manifold output for degenerate envelopes; we accept this for
non-extrudable edge cases.
- The parametric path requires a single closed outer footprint with optional
inner holes. Multi-region disconnected footprints are not supported and fall
back to B-rep.
-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
+7 -46
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,15 +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"):
z = active_obj.location.z
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":
@@ -226,25 +220,8 @@ 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
spatial.set_space_representation_from_polygon(
active_obj,
element,
space_polygon,
h,
polygon_is_si=True,
bounding_walls=bounding_walls,
container=container,
)
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
@@ -257,9 +234,7 @@ def generate_space(
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
spatial.set_space_representation_from_polygon(
obj, element, space_polygon, h, polygon_is_si=True, bounding_walls=bounding_walls, container=container
)
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
@@ -273,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)
+1 -1
View File
@@ -618,7 +618,7 @@ class Model(bonsai.core.tool.Model):
cls.edges.extend([(i, i + 1) for i in range(offset, len(cls.vertices) - 1)])
if is_closed:
cls.edges.append((len(cls.vertices) - 1, offset)) # Close the loop
cls.edges[-1] = (len(cls.vertices) - 1, offset) # Close the loop
elif curve.is_a("IfcCompositeCurve"):
# This is a first pass incomplete implementation only for simple polylines, and misses many details.
+25 -368
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,14 +34,11 @@ 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
import shapely
import shapely.affinity
import shapely.ops
from mathutils import Matrix, Vector
from natsort import natsorted
@@ -62,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
@@ -803,233 +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.
# Walls/roofs/slabs that affect the space footprint or height must be
# committed before the cache is rebuilt; otherwise the IFC geometry read by
# the iterator will be stale and a moved roof/slab will not be picked up.
affected_classes = ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES
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 affected_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
)
@classmethod
def get_space_volume_strategy(
cls,
space_polygon: shapely.Polygon,
base_z: float,
bounding_walls: list[ifcopenshell.entity_instance],
container: Optional[ifcopenshell.entity_instance] = None,
) -> tuple[str, Optional[list], Optional[list]]:
"""Decide how to build the space volume (clipped extrusion or B-rep).
Rays are cast from the RL cut elevation (``container_z + props.rl3``), the
same level at which the space footprint polygon was found.
"""
ifc_file = tool.Ifc.get()
cache = cls.get_or_build_geom_cache()
start_z = None
if container is None:
container = tool.Root.get_default_container()
if container is not None:
container_obj = tool.Ifc.get_object(container)
props = tool.Model.get_model_props()
start_z = container_obj.matrix_world.translation.z + props.rl3
tree = ifcopenshell.geom.tree(ifc_file)
settings = ifcopenshell.geom.settings()
settings.set("disable-opening-subtractions", True)
settings.set("use-world-coords", True)
tree.add_file(ifc_file, settings)
return ifcopenshell.util.space.detect_space_volume_strategy(
ifc_file, cache["shapes"], tree, space_polygon, base_z, bounding_walls, start_z=start_z
)
@classmethod
def _get_or_create_body_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
"""Return the Model/Body/MODEL_VIEW context, creating one if absent."""
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
if context is not None:
return context
# Some subcontexts may not expose the inherited ContextType value, so also
# search by ContextIdentifier/TargetView directly.
for ctx in ifc_file.by_type("IfcGeometricRepresentationSubContext"):
if ctx.ContextIdentifier == "Body" and getattr(ctx, "TargetView", None) == "MODEL_VIEW":
return ctx
# Create a minimal context if none exists.
model_context = ifcopenshell.util.representation.get_context(ifc_file, "Model")
if model_context is None:
model_context = ifc_file.createIfcGeometricRepresentationContext(
ContextType="Model",
CoordinateSpaceDimension=3,
Precision=1e-5,
WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D(
ifc_file.createIfcCartesianPoint([0.0, 0.0, 0.0])
),
TrueNorth=ifc_file.createIfcDirection([0.0, 1.0, 0.0]),
)
return ifc_file.createIfcGeometricRepresentationSubContext(
ParentContext=model_context,
ContextIdentifier="Body",
TargetView="MODEL_VIEW",
ContextType="Model",
)
@classmethod
def _remove_existing_body_representations(
cls, element: ifcopenshell.entity_instance
) -> Optional[ifcopenshell.entity_instance]:
"""Remove every existing Body representation from an element.
Returns the context of the first removed representation, or None.
"""
ifc_file = tool.Ifc.get()
if element.Representation is None:
return None
body_reps = [r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"]
context = None
for rep in body_reps:
context = rep.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=rep)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
return context
@classmethod
def set_brep_representation_from_mesh(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
item: ifcopenshell.entity_instance,
) -> None:
"""Assign a representation item (clipped solid or B-rep) to the element."""
ifc_file = tool.Ifc.get()
context = cls._remove_existing_body_representations(element)
if context is None:
context = cls._get_or_create_body_context(ifc_file)
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_body,
)
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:
@@ -1062,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()
@@ -1072,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)
@@ -1086,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:
@@ -1126,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:
@@ -1342,9 +1086,13 @@ class Spatial(bonsai.core.tool.Spatial):
curve = builder.polyline(coords_2d, closed=True)
item = builder.extrude(curve, magnitude=depth_ifc)
context = cls._remove_existing_body_representations(element)
if context is None:
context = cls._get_or_create_body_context(ifc_file)
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if old_body:
context = old_body.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
else:
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
@@ -1363,104 +1111,13 @@ class Spatial(bonsai.core.tool.Spatial):
poly: Polygon,
h: float,
polygon_is_si: bool = True,
bounding_walls: Optional[list[ifcopenshell.entity_instance]] = None,
container: Optional[ifcopenshell.entity_instance] = None,
) -> None:
"""Create or replace the IFC body representation of a space from a polygon.
:param h: The height in SI (meters).
"""
# Remove collinear points introduced by the mesh bisection so the
# footprint polygon has a minimal vertex count.
poly = poly.simplify(0, preserve_topology=True)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
ifc_file = tool.Ifc.get()
x, y, z = obj.matrix_world.translation
origin = obj.matrix_world.translation # Blender SI
# The space builders expect base_z and polygon in SI (world) units.
base_z = z
poly_si = poly if polygon_is_si else shapely.affinity.scale(poly, unit_scale, unit_scale, origin=(0, 0))
# Ensure the IFC entity has an ObjectPlacement matching the Blender object,
# so the generated representation is in the correct local coordinate system.
bpy.context.view_layer.update()
matrix = np.array(obj.matrix_world)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=element,
matrix=matrix,
is_si=True,
)
for b in list(element.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc_file, b)
cls._remove_existing_body_representations(element)
if cls.get_spatial_props().force_space_height:
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
return
if bounding_walls is None:
bounding_walls = []
if container is None:
container = ifcopenshell.util.element.get_container(element)
if container is not None:
for wall in ifc_file.by_type("IfcWall"):
if wall in ifcopenshell.util.element.get_decomposition(container):
bounding_walls.append(wall)
# Detect planes in world SI (same coordinate system as the geom cache).
strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly_si, base_z, bounding_walls, container)
# Build the geometry in the space's local coordinate system so the IFC
# representation is relative to the object's ObjectPlacement.
# Use the full inverse of the object's placement matrix so rotated spaces
# keep the correct footprint orientation.
matrix_inv = np.array(obj.matrix_world.inverted())
# shapely.affine_transform expects [a, b, d, e, xoff, yoff]
# where x' = a*x + b*y + xoff, y' = d*x + e*y + yoff.
affine_params = [
matrix_inv[0, 0],
matrix_inv[0, 1],
matrix_inv[1, 0],
matrix_inv[1, 1],
matrix_inv[0, 3],
matrix_inv[1, 3],
]
local_poly_si = shapely.affinity.affine_transform(poly_si, affine_params)
local_base_z = base_z - origin.z
def localize_plane(plane):
point, normal = plane
local_point = matrix_inv @ np.array([*point, 1.0])
rotation_inv = matrix_inv[:3, :3]
local_normal = rotation_inv @ np.array(normal)
local_normal = local_normal / np.linalg.norm(local_normal)
return (local_point[:3], local_normal)
local_top_planes = [localize_plane(p) for p in (top_planes or [])]
local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])]
if strategy == "EXTRUDE_CLIP" and top_planes:
item = ifcopenshell.util.space.build_extruded_clipped_space(
ifc_file, local_poly_si, local_base_z, local_top_planes, local_bottom_planes
)
cls.set_brep_representation_from_mesh(obj, element, item)
else:
shapes = cls.get_or_build_geom_cache()["shapes"]
local_shapes = {}
for shape_id, shape_data in shapes.items():
local_shape_data = dict(shape_data)
local_shape_data["top_z"] = shape_data["top_z"] - origin.z
local_shape_data["bottom_z"] = shape_data["bottom_z"] - origin.z
local_shapes[shape_id] = local_shape_data
item = ifcopenshell.util.space.build_brep_space(
ifc_file, element, local_shapes, local_poly_si, local_base_z
)
if item is None:
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
else:
cls.set_brep_representation_from_mesh(obj, element, item)
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
+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)
-39
View File
@@ -32,7 +32,6 @@ import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder, V
from mathutils import Matrix
import bonsai.core.tool
import bonsai.tool as tool
@@ -1045,41 +1044,3 @@ class TestGetSiblingOccurrenceCount(NewFile):
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
assert subject.get_sibling_occurrence_count(wall_type) == 2
class TestConvertCurveToMesh(NewFile):
def test_closed_polyline_converts_to_closed_loop(self):
"""A closed IfcPolyline must produce the full edge loop.
Before the fix (cls.edges[-1] = … overwrite) the closing edge
replaced the real last segment, leaving every loop open by one
edge — e.g. a quad got only 3 edges.
"""
ifc = ifcopenshell.file()
# Closed quad: 4 unique points + closing repeat = 5 points
p0 = ifc.createIfcCartesianPoint((0.0, 0.0))
p1 = ifc.createIfcCartesianPoint((1.0, 0.0))
p2 = ifc.createIfcCartesianPoint((1.0, 1.0))
p3 = ifc.createIfcCartesianPoint((0.0, 1.0))
polyline = ifc.createIfcPolyline((p0, p1, p2, p3, p0))
subject.vertices = []
subject.edges = []
subject.arcs = []
subject.circles = []
subject.unit_scale = 1.0
subject.convert_curve_to_mesh(None, Matrix(), polyline)
assert len(subject.vertices) == 4, f"Expected 4 vertices, got {len(subject.vertices)}"
assert len(subject.edges) == 4, f"Expected 4 edges, got {len(subject.edges)}"
# Every vertex must appear in exactly 2 edges (closed loop)
from collections import defaultdict
counts = defaultdict(int)
for e in subject.edges:
counts[e[0]] += 1
counts[e[1]] += 1
for v_idx, cnt in counts.items():
assert cnt == 2, f"Vertex {v_idx} has {cnt} incident edges (expected 2)"
+16 -546
View File
@@ -16,8 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import bpy
import ifcopenshell
import ifcopenshell.api
@@ -26,16 +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
import pytest
import shapely
from mathutils import Matrix, Vector
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
@@ -264,59 +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([0.0, 0.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_thin_wall(ifc, cx, cy, width, depth, height=10.0):
"""Create an IFC wall with a thin block representation centered at (cx, cy)."""
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, width, depth)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([cx, cy, 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
@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([0.0, 0.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"]
@@ -340,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"]
@@ -350,495 +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.hide_viewport = False
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, True, 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)
world_verts = [obj.matrix_world @ v.co for v in mesh.vertices]
world_zs = [v.z for v in world_verts]
assert min(world_zs) >= -0.1, f"Expected space world bottom near z>=0, got {min(world_zs)}"
assert max(world_zs) > 0, f"Expected space to have positive height, got {max(world_zs)}"
assert np.isclose(obj.location.z, 5.0, atol=0.01), f"Expected location.z=5.0, got {obj.location.z}"
class TestGenerateSpaceSlopedRoof(NewFile):
def _create_shed_roof(self, ifc, z=4.0, rise=3.0):
"""Create an IfcRoof whose underside is a sloped plane across the footprint.
Triangular prism: vertical profile (in the y-z plane) extruded along +x.
Profile points (u, v) with placement loc=(-5, 0, z), axis=(1,0,0),
ref=(0,0,1). The local frame maps u to world +z (u=0 -> z, u=rise ->
z+rise) and v to world -y (v=-5 -> y=+5, v=+5 -> y=-5):
(0,-5) -> world (-5, +5, z) eave (low) at north
(rise,-5) -> world (-5, +5, z+rise) vertical edge
(rise,5) -> world (-5, -5, z+rise) ridge at south
The underside is the sloped face from (y=+5, z) to (y=-5, z+rise).
ExtrudedDirection (0,0,1) is local, mapping to world +x; depth 10 spans
x in [-5, 5].
"""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
roof = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcRoof")
pts = [
ifc.createIfcCartesianPoint((0.0, -5.0)),
ifc.createIfcCartesianPoint((float(rise), -5.0)),
ifc.createIfcCartesianPoint((float(rise), 5.0)),
]
polyline = ifc.createIfcPolyline(pts)
profile = ifc.createIfcArbitraryClosedProfileDef(ProfileType="CURVE", OuterCurve=polyline)
placement = ifc.createIfcAxis2Placement3D(
ifc.createIfcCartesianPoint((-5.0, 0.0, z)),
ifc.createIfcDirection((1.0, 0.0, 0.0)),
ifc.createIfcDirection((0.0, 0.0, 1.0)),
)
extrude_dir = ifc.createIfcDirection((0.0, 0.0, 1.0))
solid = ifc.createIfcExtrudedAreaSolid(profile, placement, extrude_dir, 10.0)
rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [solid])
ifcopenshell.api.geometry.assign_representation(ifc, product=roof, representation=rep)
return roof
def test_generate_space_under_shed_roof(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_thin_wall(ifc, 0.0, 4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 0.0, -4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 4.8, 0.0, 0.4, 10.0)
_BlockHelper.create_thin_wall(ifc, -4.8, 0.0, 0.4, 10.0)
self._create_shed_roof(ifc, z=4.0, rise=3.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
mesh = space.data
assert isinstance(mesh, bpy.types.Mesh)
verts = np.array([v.co for v in mesh.vertices])
min_z = verts[:, 2].min()
max_z = verts[:, 2].max()
assert min_z >= -0.1
assert max_z > 0
top_z_north = max([v[2] for v in verts if v[1] > 1])
top_z_south = max([v[2] for v in verts if v[1] < -1])
assert abs(top_z_north - top_z_south) > 0.05, f"Top should slope along y: {top_z_north} vs {top_z_south}"
class TestSpaceVolumeStrategy(NewFile):
def test_vertical_box_returns_extrude_clip(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
_BlockHelper.create_slab(ifc, z=4.0)
space_polygon = shapely.box(-5, -5, 5, 5)
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [ifc.by_type("IfcWall")[0]])
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
@staticmethod
def _create_sloped_slab(ifc, z=4.0, rise=3.0):
"""Create an IfcSlab whose underside is a sloped plane across the footprint."""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
pts = [
ifc.createIfcCartesianPoint((0.0, -5.0)),
ifc.createIfcCartesianPoint((float(rise), -5.0)),
ifc.createIfcCartesianPoint((float(rise), 5.0)),
]
polyline = ifc.createIfcPolyline(pts)
profile = ifc.createIfcArbitraryClosedProfileDef(ProfileType="CURVE", OuterCurve=polyline)
placement = ifc.createIfcAxis2Placement3D(
ifc.createIfcCartesianPoint((-5.0, 0.0, z)),
ifc.createIfcDirection((1.0, 0.0, 0.0)),
ifc.createIfcDirection((0.0, 0.0, 1.0)),
)
extrude_dir = ifc.createIfcDirection((0.0, 0.0, 1.0))
solid = ifc.createIfcExtrudedAreaSolid(profile, placement, extrude_dir, 10.0)
rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [solid])
ifcopenshell.api.geometry.assign_representation(ifc, product=slab, representation=rep)
return slab
def test_sloped_slab_returns_extrude_clip(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
self._create_sloped_slab(ifc, z=4.0, rise=3.0)
space_polygon = shapely.box(-5, -5, 5, 5)
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [])
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
class TestRegenerateSpaceFromRealIfc2x3(NewFile):
def load_house_with_garage(self):
filepath = (
Path(__file__).parents[3]
/ "ifcopenshell-python"
/ "test"
/ "IfcRelSpaceBoundary_TestFiles"
/ "IfcRelSpaceBoundary2ndLevel"
/ "HouseWithGarage_AC22_IFC2X3.ifc"
).resolve()
bpy.ops.bim.load_project(filepath=filepath.as_posix())
ifc = tool.Ifc.get()
return ifc
def _regenerate_space(self, ifc, space_id):
space = ifc.by_id(space_id)
obj = tool.Ifc.get_object(space)
assert obj
import numpy as np
original_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
original_bounds = (
original_verts[:, 0].min(),
original_verts[:, 0].max(),
original_verts[:, 1].min(),
original_verts[:, 1].max(),
original_verts[:, 2].min(),
original_verts[:, 2].max(),
)
original_origin = obj.matrix_world.translation.copy()
# Delete existing related IfcRelSpaceBoundary as in the manual repro.
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.update()
# Patch Spatial helpers so generate_space uses the active IfcSpace.
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
bpy.ops.bim.generate_space()
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
regen_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
regen_bounds = (
regen_verts[:, 0].min(),
regen_verts[:, 0].max(),
regen_verts[:, 1].min(),
regen_verts[:, 1].max(),
regen_verts[:, 2].min(),
regen_verts[:, 2].max(),
)
regen_origin = obj.matrix_world.translation.copy()
return (original_bounds, original_origin), (regen_bounds, regen_origin)
def test_regenerate_space_5710_keeps_world_location(self):
ifc = self.load_house_with_garage()
(original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 5710)
assert (regen_origin - original_origin).length < 0.02
for o, r in zip(original_bounds, regen_bounds):
assert r == pytest.approx(o, abs=0.02)
def test_regenerate_space_2363_keeps_world_location(self):
ifc = self.load_house_with_garage()
(original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 2363)
assert (regen_origin - original_origin).length < 0.02
# X and Y stable; Z may differ because the regenerated space detects the
# sloped roof and clips the extrusion.
for j in (0, 1, 2, 3, 4):
assert regen_bounds[j] == pytest.approx(original_bounds[j], abs=0.02)
# Verify the regenerated body contains boolean clipping (roof clipping).
space = ifc.by_id(2363)
body = ifcopenshell.util.representation.get_representation(space, "Model", "Body", "MODEL_VIEW")
assert body is not None
boolean_items = [i for i in (body.Items or []) if i.is_a("IfcBooleanClippingResult")]
assert len(boolean_items) >= 1, "Expected roof clipping but got no boolean result"
def test_regenerate_space_twice_does_not_duplicate_half_spaces(self):
ifc = self.load_house_with_garage()
space = ifc.by_id(2363)
obj = tool.Ifc.get_object(space)
assert obj
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.update()
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
bpy.ops.bim.generate_space()
bpy.ops.bim.generate_space()
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
body_reps = [r for r in (space.Representation.Representations or []) if r.RepresentationIdentifier == "Body"]
assert len(body_reps) == 1
rep = body_reps[0]
boolean_chains = [item for item in rep.Items if item.is_a("IfcBooleanClippingResult")]
assert len(boolean_chains) <= 1
if boolean_chains:
half_space_ids = set()
for item in ifc.traverse(boolean_chains[0]):
if item.is_a("IfcHalfSpaceSolid"):
assert item.id() not in half_space_ids, "Duplicate half-space solid in boolean chain"
half_space_ids.add(item.id())
def test_regenerate_space_after_moving_roof_updates_shape(self):
ifc = self.load_house_with_garage()
space = ifc.by_id(2363)
space_obj = tool.Ifc.get_object(space)
assert space_obj
roof = ifc.by_id(5773)
roof_obj = tool.Ifc.get_object(roof)
assert roof_obj
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = space_obj
bpy.ops.object.select_all(action="DESELECT")
space_obj.select_set(True)
bpy.context.view_layer.update()
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [space_obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: space_obj)
bpy.ops.bim.generate_space()
roof_obj.hide_set(False)
roof_obj.location.z += 1.0
bpy.context.view_layer.update()
tool.Geometry.commit_placement_if_moved(roof_obj)
bpy.ops.bim.generate_space()
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
body_reps = [r for r in (space.Representation.Representations or []) if r.RepresentationIdentifier == "Body"]
assert len(body_reps) == 1
def test_regenerate_space_is_stable_across_multiple_iterations(self):
"""Regenerating the same space 5+ times must produce identical Z and bounds."""
ifc = self.load_house_with_garage()
space = ifc.by_id(2363)
obj = tool.Ifc.get_object(space)
assert obj
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.update()
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
def snapshot():
verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=float)
return (
obj.matrix_world.translation.copy(),
(
float(verts[:, 0].min()),
float(verts[:, 0].max()),
float(verts[:, 1].min()),
float(verts[:, 1].max()),
float(verts[:, 2].min()),
float(verts[:, 2].max()),
),
)
snapshots = []
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
for _ in range(5):
bpy.ops.bim.generate_space()
snapshots.append(snapshot())
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
ref_origin, ref_bounds = snapshots[0]
for i, (origin, bounds) in enumerate(snapshots[1:], start=1):
assert (
origin - ref_origin
).length < 0.02, f"Iteration {i}: Z drifted from {list(ref_origin)} to {list(origin)}"
for j, (o, r) in enumerate(zip(ref_bounds, bounds)):
assert r == pytest.approx(
o, abs=0.02
), f"Iteration {i} axis {j}: {o} != {r} full ref={ref_bounds} cur={bounds}"
class TestGenerateSpaceLocation(NewFile):
def test_generate_space_at_non_zero_cursor_location(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
# 4 thin walls forming a hollow box around (10, 20).
_BlockHelper.create_thin_wall(ifc, 10.0, 20.0 + 4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 10.0, 20.0 - 4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 10.0 + 4.8, 20.0, 0.4, 10.0)
_BlockHelper.create_thin_wall(ifc, 10.0 - 4.8, 20.0, 0.4, 10.0)
bpy.context.scene.cursor.location = (10, 20, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
mesh = space.data
assert isinstance(mesh, bpy.types.Mesh)
world_verts = np.array([space.matrix_world @ v.co for v in mesh.vertices])
center = (world_verts.min(axis=0) + world_verts.max(axis=0)) / 2
assert center[0] == pytest.approx(10.0, abs=0.1)
assert center[1] == pytest.approx(20.0, abs=0.1)
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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,627 +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 Literal, Optional, Union
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.util.boundary
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder
import ifcopenshell.util.unit
import numpy as np
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
def _nearest_ray_hits(
tree: ifcopenshell.geom.tree,
origins: list[tuple[float, float, float]],
ray_dir: np.ndarray,
) -> list[ifcopenshell.geom.hit]:
"""Nearest hit per origin; select_ray returns all hits including duplicates."""
hits = []
for origin in origins:
results = sorted(tree.select_ray(origin, ray_dir, 1e4), key=lambda h: h.distance)
if results:
hits.append(results[0])
return hits
def get_vertical_bounding_planes(
ifc_file: ifcopenshell.file,
shapes: dict,
tree: ifcopenshell.geom.tree,
space_polygon: shapely.Polygon,
base_z: float,
direction: Literal["UP", "DOWN"],
start_z: Optional[float] = None,
) -> tuple[str, list[tuple[np.ndarray, np.ndarray]]]:
"""Detect the top or bottom bounding planes for a space footprint.
Rays are cast from ``start_z`` (the RL cut elevation passed by the Bonsai
tool layer) so they start in the same horizontal slice of the room where
the footprint polygon was found. When ``start_z`` is None, rays start at
``base_z + 0.001``.
:param ifc_file: The IFC file.
:param shapes: Cached element shapes keyed by element id.
:param tree: Geometry tree with all bounding elements added.
:param space_polygon: Space footprint in world XY.
:param base_z: Base elevation of the space in SI.
:param direction: "UP" for top (ceiling/roof) or "DOWN" for bottom (floor/slab).
:param start_z: Elevation to cast rays from in SI (the RL cut level).
:return: (strategy, planes). Strategy is always "EXTRUDE_CLIP"; an empty
planes list means open top (direction="UP") or void below
(direction="DOWN"). The strategy decision between extrusion and B-rep
happens in the calling layer. Planes are (point, normal) tuples in SI;
the normal points toward the removed side (half-space convention).
"""
ray_dir = np.array([0.0, 0.0, 1.0]) if direction == "UP" else np.array([0.0, 0.0, -1.0])
origin_z = start_z if start_z is not None else base_z + 0.001
bounds = space_polygon.bounds
cx = (bounds[0] + bounds[2]) / 2.0
cy = (bounds[1] + bounds[3]) / 2.0
sample_points = [(cx, cy)]
if bounds[2] - bounds[0] > 0.1:
sample_points.append((cx + 0.25 * (bounds[2] - bounds[0]), cy))
sample_points.append((cx - 0.25 * (bounds[2] - bounds[0]), cy))
if bounds[3] - bounds[1] > 0.1:
sample_points.append((cx, cy + 0.25 * (bounds[3] - bounds[1])))
sample_points.append((cx, cy - 0.25 * (bounds[3] - bounds[1])))
# Sample from footprint corners, offset toward centroid so the ray origin
# sits inside the space (not on a bounding wall). This catches elements
# (e.g. sloped roofs) that only cover a corner of the space.
for coords in space_polygon.exterior.coords[:-1]:
vx, vy = coords[0], coords[1]
sample_points.append((vx + 0.05 * (cx - vx), vy + 0.05 * (cy - vy)))
hits = _nearest_ray_hits(tree, [(x, y, origin_z) for x, y in sample_points], ray_dir)
if not hits:
return "EXTRUDE_CLIP", [] # open top / void below: no bounding planes
tol_floor = 0.05
plane_hits = []
for result in hits:
point = np.array(result.position, dtype=float)
normal = np.array(result.normal, dtype=float)
if abs(normal[2]) < 0.5:
continue # vertical face; not a top/bottom bounding plane
if direction == "UP" and point[2] < base_z - tol_floor:
continue # RL below the space base: ignore hits under it
if direction == "DOWN" and abs(point[2] - base_z) < tol_floor:
continue # flat floor at the space base: no bottom clip needed
plane_hits.append((point, normal))
tol_normal = 0.02
tol_distance = 0.05
plane_groups: list[tuple[np.ndarray, list[np.ndarray]]] = []
for point, normal in plane_hits:
added = False
for anchor, members in plane_groups:
plane_normal = np.array(members[0])
if np.linalg.norm(normal - plane_normal) < tol_normal:
if abs(np.dot(point - anchor, plane_normal)) < tol_distance:
members.append(normal)
added = True
break
if not added:
plane_groups.append((point, [normal]))
planes = []
for anchor, normals in plane_groups:
mean_normal = np.mean(normals, axis=0)
mean_normal /= np.linalg.norm(mean_normal)
if np.dot(mean_normal, ray_dir) < 0:
mean_normal = -mean_normal
planes.append((anchor, mean_normal))
return "EXTRUDE_CLIP", planes
def detect_space_volume_strategy(
ifc_file: ifcopenshell.file,
shapes: dict,
tree: ifcopenshell.geom.tree,
space_polygon: shapely.Polygon,
base_z: float,
bounding_walls: list[ifcopenshell.entity_instance],
start_z: Optional[float] = None,
) -> tuple[str, Optional[list], Optional[list]]:
"""Decide whether a space can be represented as a clipped extrusion.
A space is "EXTRUDE_CLIP" when all bounding walls have vertical side faces
and the detected top/bottom bounding planes are few and piecewise-planar
(0-2 top planes, 0-1 bottom plane). Otherwise it is "BREP".
:param ifc_file: The IFC file.
:param shapes: Cached element shapes.
:param tree: Geometry tree with bounding elements.
:param space_polygon: Space footprint in world XY.
:param base_z: Base elevation in SI.
:param bounding_walls: List of wall elements bounding the space.
:param start_z: Ray-cast origin elevation (RL cut level) in SI.
:return: ("EXTRUDE_CLIP", top_planes, bottom_planes) or ("BREP", None, None).
"""
tol = 0.02
for wall in bounding_walls:
shape_data = shapes.get(wall.id())
if not shape_data:
continue
verts = shape_data["verts"]
faces = shape_data["faces"]
if len(verts) == 0 or len(faces) == 0:
continue
v1 = verts[faces[:, 1]] - verts[faces[:, 0]]
v2 = verts[faces[:, 2]] - verts[faces[:, 0]]
normals = np.cross(v1, v2)
norms = np.linalg.norm(normals, axis=1)
normals = normals[norms > 1e-8]
if len(normals) == 0:
continue
normals = normals / np.linalg.norm(normals, axis=1)[:, np.newaxis]
side_mask = np.abs(normals[:, 2]) < 0.5
if np.any(side_mask) and np.mean(np.abs(normals[side_mask, 2])) > tol:
return "BREP", None, None
_, top_planes = get_vertical_bounding_planes(ifc_file, shapes, tree, space_polygon, base_z, "UP", start_z=start_z)
_, bottom_planes = get_vertical_bounding_planes(
ifc_file, shapes, tree, space_polygon, base_z, "DOWN", start_z=start_z
)
if len(top_planes) > 2 or len(bottom_planes) > 1:
return "BREP", None, None
return "EXTRUDE_CLIP", top_planes, bottom_planes
def _footprint_coords(space_polygon: shapely.Polygon):
"""Yield (x, y) boundary coordinates of a footprint polygon (exterior then holes)."""
for coords in [space_polygon.exterior.coords, *[ring.coords for ring in space_polygon.interiors]]:
for point in coords:
yield point[0], point[1]
def build_extruded_clipped_space(
ifc_file: ifcopenshell.file,
space_polygon: shapely.Polygon,
base_z: float,
top_planes: list[tuple[np.ndarray, np.ndarray]],
bottom_planes: list[tuple[np.ndarray, np.ndarray]],
) -> ifcopenshell.entity_instance:
"""Build an IfcExtrudedAreaSolid clipped to top/bottom planes.
:param ifc_file: The IFC file.
:param space_polygon: Footprint polygon in world XY.
:param base_z: Base elevation in SI.
:param top_planes: List of (point, normal) tuples for top clipping planes.
:param bottom_planes: List of (point, normal) tuples for bottom clipping planes.
:return: IfcBooleanClippingResult chain.
"""
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
centroid = np.array([space_polygon.centroid.x, space_polygon.centroid.y])
exterior = [
(float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale)
for p in space_polygon.exterior.coords[:-1]
]
inner_curves = []
for interior in space_polygon.interiors:
inner = [
(float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale)
for p in interior.coords[:-1]
]
inner_curve = builder.polyline(inner, closed=True)
inner_curves.append(inner_curve)
outer_curve = builder.polyline(exterior, closed=True)
profile = builder.profile(outer_curve, inner_curves=inner_curves)
all_z = [base_z]
for point, normal in top_planes + bottom_planes:
all_z.append(float(point[2]))
for x, y in _footprint_coords(space_polygon):
# A sloped plane's height varies across the footprint. The anchor
# point is near the centre, so also cover the plane at the polygon
# vertices, otherwise the high side of a sloped ceiling is capped
# below the plane it should reach.
if abs(normal[2]) < 1e-6:
continue
plane_z = (float(np.dot(normal, point)) - float(normal[0]) * x - float(normal[1]) * y) / float(normal[2])
all_z.append(plane_z)
min_z = min(all_z)
max_z = max(all_z)
height = max_z - min_z
extrusion = builder.extrude(
profile,
magnitude=height / unit_scale,
position=[(centroid[0] / unit_scale), (centroid[1] / unit_scale), min_z / unit_scale],
)
result = extrusion
for point, normal in top_planes + bottom_planes:
# clip_solid takes location in SI; it converts to project units internally.
result = ifcopenshell.api.geometry.clip_solid(
ifc_file,
item=result,
location=[float(point[i]) for i in range(3)],
normal=[float(normal[i]) for i in range(3)],
)
return result
def _build_local_shapes(ifc_file: ifcopenshell.file) -> dict:
"""Build the local-coordinates shapes dict required by auto_generate_boundaries.
Keys are element ids; values have ``verts`` (local), ``faces``, ``edges``
and ``matrix`` as produced by ``ifcopenshell.geom.iterator``.
"""
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()
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 build_brep_space(
ifc_file: ifcopenshell.file,
space: ifcopenshell.entity_instance,
shapes: dict,
space_polygon: shapely.Polygon,
base_z: float,
) -> Union[ifcopenshell.entity_instance, None]:
"""Build a closed-shell B-rep space from auto-generated boundary faces.
Seeds the space with a temporary extrusion spanning the bounding elements'
vertical extent, generates 1st-level boundaries with
``auto_generate_boundaries``, then merges the boundary polygons into a
closed mesh (``IfcFacetedBrep``/``IfcPolygonalFaceSet``).
:param ifc_file: The IFC file.
:param space: The IfcSpace entity.
:param shapes: Cached element shapes (world coords) for the z-extent.
:param space_polygon: Footprint polygon in world XY.
:param base_z: Base elevation in SI.
:return: IfcFacetedBrep or IfcPolygonalFaceSet, or None if boundaries
cannot be resolved.
"""
all_z = [base_z]
for shape_data in shapes.values():
all_z.append(shape_data["top_z"])
all_z.append(shape_data["bottom_z"])
min_z = min(all_z)
max_z = max(all_z)
height = max_z - min_z
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
centroid = np.array([space_polygon.centroid.x, space_polygon.centroid.y])
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
exterior = [
(float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale)
for p in space_polygon.exterior.coords[:-1]
]
outer_curve = builder.polyline(exterior, closed=True)
profile = builder.profile(outer_curve)
ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
if ctx is None:
return None
seed = builder.extrude(
profile,
magnitude=height / unit_scale,
position=[centroid[0] / unit_scale, centroid[1] / unit_scale, min_z / unit_scale],
)
seed_rep = builder.get_representation(ctx, seed)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=space, representation=seed_rep)
local_shapes = _build_local_shapes(ifc_file)
boundary_class = "IfcRelSpaceBoundary1stLevel"
if ifc_file.schema == "IFC2X3":
boundary_class = "IfcRelSpaceBoundary"
boundaries = ifcopenshell.util.boundary.auto_generate_boundaries(
ifc_file, space, local_shapes, boundary_class=boundary_class
)
if isinstance(boundaries, str) or not boundaries:
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep)
return None
points = []
point_index = {}
faces = []
def add_point(p):
key = tuple(np.round(p, 5))
if key not in point_index:
point_index[key] = len(points)
points.append([float(c) for c in p])
return point_index[key]
for boundary in boundaries:
connection = boundary.ConnectionGeometry
if connection is None:
continue
surface = connection.SurfaceOnRelatingElement
if surface is None or not surface.is_a("IfcCurveBoundedPlane"):
continue
outer = surface.OuterBoundary
if outer is None or not outer.is_a("IfcPolyline"):
continue
matrix = ifcopenshell.util.placement.get_axis2placement(surface.BasisSurface.Position)
poly_points = []
for loop_point in outer.Points:
local = np.array([float(c) for c in loop_point.Coordinates])
if len(local) == 2:
local = np.array([*local, 0.0])
world = np.delete(matrix @ np.array([*local, 1.0]), 3)
poly_points.append(world)
if len(poly_points) >= 3:
faces.append([add_point(p) for p in poly_points])
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep)
if not faces:
return None
tri_faces = []
for face in faces:
for i in range(1, len(face) - 1):
tri_faces.append([face[0], face[i], face[i + 1]])
return builder.mesh(points, tri_faces)
@@ -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,440 +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, direction=(0.0, 0.0, 1.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((float(direction[0]), float(direction[1]), float(direction[2])))
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_generates_boundary_for_sloped_wall(self):
# A wall whose inner face is tilted away from the space (extrusion
# direction (0, 0.5, 1.0) makes the face ~153 deg from the seed face
# normal) previously matched no seed face, so no boundary was
# generated. The relaxed fallback match should attribute the space's
# north face to the wall.
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, direction=(0.0, 0.5, 1.0))
shapes = _build_shapes_dict(self.file, [space, wall])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
assert isinstance(result, list)
wall_boundaries = _boundaries_for(result, wall)
assert len(wall_boundaries) == 1
assert wall_boundaries[0].PhysicalOrVirtualBoundary == "PHYSICAL"
assert _outer_boundary_area(wall_boundaries[0]) == pytest.approx(26.83, abs=1e-2)
def test_generates_boundary_for_sloped_roof(self):
# A roof slab extruded at a tilt so its underside is a sloped plane
# (~163 deg from the seed top face normal) cutting across the space's
# top face. Previously no seed face matched it, so no boundary was
# generated.
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
roof = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
_add_extruded_body(
self.file, roof, [[-5, -7], [5, -7], [5, -5], [-5, -5]], 2.0, z_offset=3.2, direction=(0.0, 1.0, 0.3)
)
shapes = _build_shapes_dict(self.file, [space, roof])
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
assert isinstance(result, list)
roof_boundaries = _boundaries_for(result, roof)
assert len(roof_boundaries) == 1
assert roof_boundaries[0].PhysicalOrVirtualBoundary == "PHYSICAL"
assert _outer_boundary_area(roof_boundaries[0]) == pytest.approx(19.16, abs=1e-2)
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.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.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.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.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,344 +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 math
import numpy as np
import os
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, direction=(0.0, 0.0, 1.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(direction)
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
class TestGetVerticalBoundingPlanes(test.bootstrap.IFC4):
def test_flat_ceiling_returns_single_plane(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])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
space_polygon = shapely.box(-5, -5, 5, 5)
strategy, planes = subject.get_vertical_bounding_planes(
self.file, shapes, tree, space_polygon, base_z=0.0, direction="UP"
)
assert strategy == "EXTRUDE_CLIP"
assert len(planes) == 1
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
assert np.allclose(planes[0][1], [0.0, 0.0, 1.0], atol=0.01)
def test_flat_ceiling_returns_single_plane_from_rl_origin(self):
# Same result when rays are cast from an RL cut elevation (z=1.0)
# instead of the default base_z + 0.001.
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])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, planes = subject.get_vertical_bounding_planes(
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), base_z=0.0, direction="UP", start_z=1.0
)
assert strategy == "EXTRUDE_CLIP"
assert len(planes) == 1
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
class TestDetectSpaceVolumeStrategy(test.bootstrap.IFC4):
def test_vertical_walls_flat_slab_returns_extrude_clip(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])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, top, bottom = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), 0.0, [wall]
)
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
def test_vertical_walls_flat_slab_returns_extrude_clip_from_rl_origin(self):
# Same as above but rays cast from an RL cut elevation (z=1.0).
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])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, top, bottom = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), 0.0, [wall], start_z=1.0
)
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
def test_sloped_wall_returns_brep(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]], 6.0, direction=(0.0, 0.5, 1.0))
shapes = _build_shapes_dict(self.file, [wall])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, _, _ = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-4, -4, 4, 4), 0.0, [wall]
)
assert strategy == "BREP"
def test_curved_vertical_wall_returns_extrude_clip(self):
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
pts = []
for i in range(9):
angle = -math.pi / 2.0 + math.pi * i / 8.0
pts.append((5.0 * math.cos(angle), 5.0 * math.sin(angle)))
_add_extruded_body(self.file, wall, pts, 6.0)
shapes = _build_shapes_dict(self.file, [wall])
tree = ifcopenshell.geom.tree(self.file)
tree.add_file(self.file, ifcopenshell.geom.settings())
strategy, top, bottom = subject.detect_space_volume_strategy(
self.file, shapes, tree, shapely.box(-4, -4, 4, 4), 0.0, [wall]
)
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
class TestBuildExtrudedClippedSpace(test.bootstrap.IFC4):
def test_shed_roof_clips_to_sloped_plane(self):
space_polygon = shapely.box(-5, -5, 5, 5)
top_plane = (np.array([0.0, 0.0, 4.0]), np.array([0.0, 0.0, 1.0]))
bottom_plane = (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, -1.0]))
item = subject.build_extruded_clipped_space(self.file, space_polygon, 0.0, [top_plane], [bottom_plane])
assert item.is_a("IfcBooleanClippingResult")
assert item.SecondOperand.is_a("IfcHalfSpaceSolid")
# Chain: bottom clip -> top clip -> IfcExtrudedAreaSolid.
assert item.FirstOperand.is_a("IfcBooleanClippingResult")
assert item.FirstOperand.FirstOperand.is_a("IfcExtrudedAreaSolid")
def test_sloped_top_plane_reaches_footprint_max(self):
# A sloped ceiling's high side must reach the plane at the footprint
# edge (z=7.0 at y=-5), not be capped at the plane anchor z=5.5.
# 3D coords guard against the polygon carrying Z from the bisection.
space_polygon = shapely.Polygon([(-5, -5, 0), (5, -5, 0), (5, 5, 0), (-5, 5, 0)])
top_plane = (np.array([0.0, 0.0, 5.5]), np.array([0.0, 0.287, 0.958]))
bottom_plane = (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, -1.0]))
item = subject.build_extruded_clipped_space(self.file, space_polygon, 0.0, [top_plane], [bottom_plane])
shape = ifcopenshell.geom.create_shape(ifcopenshell.geom.settings(), item)
verts = ifcopenshell.util.shape.get_vertices(shape)
assert verts[:, 2].min() == pytest.approx(0.0, abs=0.1)
assert verts[:, 2].max() == pytest.approx(7.0, abs=0.1)
class TestBuildBrepSpace(test.bootstrap.IFC4):
def test_sloped_wall_brep_has_faces(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]], 6.0, direction=(0.0, 0.5, 1.0))
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
shapes = _build_shapes_dict(self.file, [wall])
item = subject.build_brep_space(self.file, space, shapes, shapely.box(-4, -4, 4, 4), 0.0)
assert item is not None
assert item.is_a("IfcFacetedBrep") or item.is_a("IfcPolygonalFaceSet")
class TestBuildBrepSpaceIfc2x3:
def test_build_brep_space_on_ifc2x3_uses_rel_space_boundary(self):
# IFC2X3 does not have IfcRelSpaceBoundary1stLevel; build_brep_space must
# fall back to plain IfcRelSpaceBoundary without raising a schema error.
# We use the real IFC2X3 fixture because the installed wrapper in this
# environment has a quirk with synthetic IFC2X3 geometry creation.
path = os.path.join(
os.path.dirname(__file__),
"..",
"IfcRelSpaceBoundary_TestFiles",
"IfcRelSpaceBoundary2ndLevel",
"HouseWithGarage_AC22_IFC2X3.ifc",
)
ifc_file = ifcopenshell.open(path)
space = ifc_file.by_type("IfcSpace")[0]
shapes = _build_shapes_dict(ifc_file, ifc_file.by_type("IfcWall") + ifc_file.by_type("IfcSlab"))
item = subject.build_brep_space(ifc_file, space, shapes, shapely.box(-1, -1, 1, 1), 0.0)
assert item is not None
assert item.is_a("IfcFacetedBrep") or item.is_a("IfcPolygonalFaceSet")
+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"