Compare commits

...

22 Commits

Author SHA1 Message Date
CyrilWaechter 0e084f2830 Port space/boundary code to v0.9.0 API changes
v0.9.0 changed two APIs used by the space generation feature:

1. geom.tree.select_ray no longer accepts length as a keyword argument;
   pass it positionally.

2. ifcopenshell.file no longer exposes wrapped_data; serialize with
   file.to_string() directly.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter dd5bd58916 Add footprint corner sampling for roof detection
get_vertical_bounding_planes now also casts rays from the footprint
polygon's corner vertices, offset slightly inward toward the centroid.
This catches bounding elements (e.g. sloped roofs) that only cover a
corner of the space.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter c3abe0b3c7 Fix space regeneration determinism and caching bugs
Fix three issues in generate_space:

1. Z location drift: z was derived from the Blender bounding box, which
   changes after every regeneration. Use active_obj.location.z instead.

2. Cache invalidation for moved roofs/slabs: commit placements for
   HEIGHT_DETECTION_CLASSES in addition to BOUNDING_CLASSES so the
   geometry cache reflects recent moves.

3. Non-deterministic regeneration: the old Body representation was still
   present in the IFC file when get_space_volume_strategy built the
   geometry tree, so ray hits from get_vertical_bounding_planes hit the
   space's own body. Since each regeneration produced a different Body
   (BooleanClippingResult/FacetedBrep), the strategy alternated between
   EXTRUDE_CLIP and BREP. Remove all Body representations before
   strategy detection so the tree only contains bounding elements.

Also clean up stale IfcRelSpaceBoundary relationships before each
regeneration to prevent old boundary references from contaminating
subsequent runs. Remove ALL existing Body representations (not just
the first one found) to prevent duplicate half-space clipping chains.

Add regression tests including a 5-iteration stability check.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter 7017d5400d Fix rotated space placement localization during regeneration
set_space_representation_from_polygon was localising the footprint and
clipping planes by subtracting only the object origin. For spaces with a
rotated ObjectPlacement (e.g. Space 5710 in the test IFC) the footprint
was not rotated into the space's local coordinate system, so the
regenerated mesh was rotated by the placement angle and appeared at the
wrong world location.

Now the polygon and planes are transformed with the full inverse of the
object's placement matrix, and the plane normals are also rotated. The
local mesh is therefore aligned with the object's local axes and appears
in the correct world position when the placement is applied.

Added regression tests for Space 5710 (rotated placement) and Space 2363
(identity placement) using the real HouseWithGarage_AC22_IFC2X3.ifc
fixture.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter fb3cd09d6d Fix space generation location and IFC2X3 B-rep
Two fixes for space generation:

1. IFC2X3 schema: build_brep_space now falls back to plain
   IfcRelSpaceBoundary because IfcRelSpaceBoundary1stLevel does not exist in
   IFC2X3.

2. Geometry location: set_space_representation_from_polygon now aligns the
   IFC ObjectPlacement with the Blender object, converts base_z/planes and
   the footprint polygon to the object's local coordinate system before
   building, and fixes the base_z unit scale. The centred-cube regeneration
   test was updated to check world bounds because the mesh is now placed
   relative to the object placement.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter 332435416a Fix clipped space top reaching sloped planes
The extrusion height was capped at the top/bottom plane anchor z (the mean
of the ray-cast hits, near the footprint centre), so a sloped ceiling's
high side stopped short of the plane (e.g. 5.5 m instead of 6.88 m for the
shed roof test). Extend the extrusion to the plane's z at every footprint
vertex before clipping, falling back to the base z as before. Also correct
the mirrored profile-to-world mapping comment in the shed roof test helper
(the ridge is at world y=-5, not y=+5).

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter f0c6de4bdf Add regression tests for sloped slab, curved wall
Cover a curved vertical wall (EXTRUDE_CLIP strategy) in test_space.py and a
sloped slab (clipped extrusion) in test_spatial.py.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter 665c5fa77e Pass bounding walls into space representation
Thread the footprint-query bounding walls and container into the
set_space_representation_from_polygon dispatcher and add an end-to-end
test for a space under a shed roof with a sloped underside.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter de13379162 Wire space volume strategy detection
Dispatch on EXTRUDE_CLIP vs B-rep when building space volumes, and
fix fixture placement and visibility bugs in the spatial tests.

Generated with the assistance of an AI coding tool.
2026-08-18 00:09:56 +02:00
CyrilWaechter 3b16356181 Add B-rep fallback space builder
Build a faceted B-rep space from auto-generated boundary faces when
sloped or curved bounding elements make a clipped extrusion
unsuitable.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 44860cd615 Match sloped faces in boundary generation
Allow sloped roof/slab/wall faces to bound space faces when the
strict anti-parallel rule leaves a face uncovered, using a
footprint-scaled distance tolerance. Existing matching behaviour
is preserved (fallback-only).

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 5651cd6494 Add clipped extrusion space builder
Build IfcExtrudedAreaSolid clipped by top/bottom half-space planes.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 29b9d8807e Add space volume strategy detection
Detect whether a space can be built as a clipped extrusion or needs
a B-rep fallback, based on wall face orientation and top/bottom
bounding planes.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 37f557b4b5 Align vertical bounding plane strategy contract with caller
get_vertical_bounding_planes always returns EXTRUDE_CLIP; the
EXTRUDE_CLIP-vs-BREP decision belongs to the calling layer.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 881fb10fe6 Add vertical bounding plane detection for space generation
Implement get_vertical_bounding_planes using ray-casting from the RL
cut elevation with nearest-hit and coplanar grouping.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 1949adda44 Expand space regeneration design spec with prior art
Add prior-art references, known limitations, and non-goals
identified during self-review.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 87193ac323 Add space regeneration sloped-roof design spec
Design for extending generate_space with a hybrid parametric
extrusion + clipping / B-rep fallback strategy supporting sloped
roofs, sloped walls, sloped slabs, and curved walls.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter c34a6ddac6 Bonsai: add regression test for closed IfcPolyline loop conversion
Verifies that convert_curve_to_mesh produces the closing edge instead of overwriting the last segment, matching the fix from PR #8043.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
Petru Conduraru 92cc601a85 Bonsai: close IfcPolyline loops by appending the closing edge (#8043)
convert_curve_to_mesh built the edge chain of a polyline with extend, then for a
closed polyline overwrote the last edge with the closing edge instead of
appending it. That discarded the final real segment, so every closed IfcPolyline
loop came back one edge short and open. On the edit mode round trip the inner
void loop of an IfcArbitraryProfileDefWithVoids was then lost or misclassified,
and the profile was rewritten without its void, collapsing the extrusion to a
bounding box.

Append the closing edge instead, matching the IfcIndexedPolyCurve branch. Live
tested: the Tab round trip now keeps both loops closed and re-exports the
IfcArbitraryProfileDefWithVoids with its inner void intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 00:06:27 +02:00
CyrilWaechter adf01be1d0 ifcopenshell.util.boundary: make face matrix robust against collinear first vertices
_face_matrix_from_verts used only the first 3 vertices and sb.np_normal, which divides by zero when they are collinear. Triangulated meshes from generated spaces often start with collinear boundary vertices, producing NaN matrices and a shapely LinearRing error. Walk the polygon to find a non-degenerate normal and edge.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter c8f8196b09 Bonsai: commit moved bounding objects before IFC-based space generation
The auto-generate-ifc-based-space-boundaries path builds a geometry cache from the IFC file. If a user (or a BDD helper) only moves the Blender object matrix, the cache still sees the old IFC placement and the space footprint is open. Commit any moved visible bounding objects and clear the cache before generating the space.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter cb22dd7a43 Use space's own container for regeneration instead of requiring default
When regenerating an existing IfcSpace, the default container is no longer required. Instead, the space's container is found via get_parent(element), which walks the full spatial hierarchy (aggregation, containment, nesting). For new space creation, the default container is still required.

Add optional container parameter to get_space_polygon_from_context_visible_objects so regeneration can pass the resolved container directly.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
10 changed files with 1563 additions and 111 deletions
@@ -0,0 +1,168 @@
# 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.
+23 -8
View File
@@ -20,9 +20,10 @@ 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
@@ -186,9 +187,6 @@ 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
@@ -206,7 +204,15 @@ def generate_space(
else:
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y)
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)
if isinstance(space_polygon, str):
if space_polygon == "NO POLYGONS FOUND":
@@ -230,8 +236,15 @@ def generate_space(
if element and element.is_a("IfcSpace"):
assert active_obj
active_obj.location.z = z
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
spatial.set_space_representation_from_polygon(
active_obj,
element,
space_polygon,
h,
polygon_is_si=True,
bounding_walls=bounding_walls,
container=container,
)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
@@ -244,7 +257,9 @@ 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)
spatial.set_space_representation_from_polygon(
obj, element, space_polygon, h, polygon_is_si=True, bounding_walls=bounding_walls, container=container
)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
+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[-1] = (len(cls.vertices) - 1, offset) # Close the loop
cls.edges.append((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.
+217 -10
View File
@@ -42,6 +42,7 @@ 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
@@ -874,16 +875,32 @@ class Spatial(bonsai.core.tool.Spatial):
return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z)
@classmethod
def get_space_polygon_from_context_visible_objects(cls, x: float, y: float) -> tuple[
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
container = tool.Root.get_default_container()
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):
@@ -911,6 +928,109 @@ class Spatial(bonsai.core.tool.Spatial):
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,
)
@classmethod
def debug_shape(cls, foo: shapely.Polygon) -> None:
coords = [(p[0], p[1], 0) for p in foo.exterior.coords]
@@ -1222,13 +1342,9 @@ class Spatial(bonsai.core.tool.Spatial):
curve = builder.polyline(coords_2d, closed=True)
item = builder.extrude(curve, magnitude=depth_ifc)
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")
context = cls._remove_existing_body_representations(element)
if context is None:
context = cls._get_or_create_body_context(ifc_file)
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
@@ -1247,13 +1363,104 @@ 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())
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
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)
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
+39
View File
@@ -32,6 +32,7 @@ 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
@@ -1044,3 +1045,41 @@ 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)"
+366 -10
View File
@@ -16,6 +16,8 @@
# 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,7 +28,9 @@ import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.util.representation
import numpy as np
from mathutils import Matrix
import pytest
import shapely
from mathutils import Matrix, Vector
import bonsai.core.tool
import bonsai.tool as tool
@@ -270,7 +274,7 @@ class _BlockHelper:
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 10.0, 10.0)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-5.0, -5.0, 0.0]))
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
)
@@ -278,6 +282,21 @@ class _BlockHelper:
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}."""
@@ -285,7 +304,7 @@ class _BlockHelper:
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 12.0, 12.0)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-6.0, -6.0, z]))
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])
@@ -384,6 +403,7 @@ class TestGenerateSpace(NewFile):
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()
@@ -456,7 +476,7 @@ class TestGenerateSpace(NewFile):
ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]),
ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]),
]
face_set = ifc.createIfcPolygonalFaceSet(points, closed=True, faces=faces)
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")
@@ -480,9 +500,345 @@ class TestGenerateSpace(NewFile):
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
verts = [v.co.z for v in mesh.vertices]
min_z = min(verts)
max_z = max(verts)
assert min_z >= 0, f"Expected extrusion to start at local z>=0, got min_z={min_z}"
assert max_z > 0, f"Expected extrusion to have positive height, got max_z={max_z}"
assert np.isclose(obj.location.z, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}"
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)
@@ -261,7 +261,7 @@ def auto_generate_boundaries(
)
space_face_normal_world = space_matrix_3x3 @ space_face_normal_local
face_matrix = _face_matrix_from_verts(space_verts_l[:3])
face_matrix = _face_matrix_from_verts(space_verts_l)
face_matrix_inv = np.linalg.inv(face_matrix)
space_face_polygon = _verts_to_polygon(space_verts_l, face_matrix_inv, snap=1e-6)
if not space_face_polygon.is_valid:
@@ -281,14 +281,27 @@ def auto_generate_boundaries(
space_face_normals_world[space_ngon_idx] = space_face_normal_world
covered_by_face[space_ngon_idx] = []
candidates = []
for element in building_elements:
if element.id() in all_filling_ids:
continue
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
continue
match = _match_element_to_space_face(
element,
surviving_candidates = _match_space_face_candidates(
building_elements,
shapes,
element_ngons,
space_matrix,
space_matrix_inv,
space_verts_l,
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
all_filling_ids,
matched_walls_and_columns,
)
# A sloped roof/slab/wall face is not anti-parallel to the space face
# it bounds, so it fails the strict gate above and leaves the face
# uncovered. Run a relaxed matching pass as a fallback so that such
# faces still generate a boundary.
if not surviving_candidates:
surviving_candidates = _match_space_face_candidates(
building_elements,
shapes,
element_ngons,
space_matrix,
@@ -297,59 +310,10 @@ def auto_generate_boundaries(
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
all_filling_ids,
matched_walls_and_columns,
relaxed=True,
)
if match is None:
continue
dist_min, plane_offset_min, matching_polygons, matched_elem_normal = match
if len(matching_polygons) == 1:
gross_boundary_polygon = matching_polygons[0]
else:
gross_boundary_polygon = shapely.ops.unary_union(matching_polygons)
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid):
continue
if gross_boundary_polygon.is_empty:
continue
candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
# A space face may be matched by several elements within the distance
# tolerance (e.g. a second wall layer or an element end cap). The
# element closest to the face is the actual bounding surface, so
# candidates are kept in order of increasing plane offset (ties broken
# by polygon area). A candidate whose polygon is entirely covered by
# the candidates already kept is redundant and is absorbed into the
# larger boundary. This includes coplanar candidates: e.g. wall end
# caps that are coplanar with the ceiling and fully covered by the
# slab above do not get their own boundary in the reference output.
surviving_candidates = []
kept_union = None
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in sorted(
candidates, key=lambda c: (c[2] if c[2] is not None else float("inf"), -c[3].area)
):
if kept_union is not None and gross_boundary_polygon.difference(kept_union).area < 1e-4:
continue
surviving_candidates.append(
(element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
)
kept_union = gross_boundary_polygon if kept_union is None else kept_union.union(gross_boundary_polygon)
# When a single element bounds the space face and its face is (nearly)
# coplanar with it, the boundary covers the full space face (1st level
# semantics) rather than the clipped intersection with the element
# face. This matches the reference output and avoids leaving corner
# slivers to be filled by an extra gap boundary.
if len(surviving_candidates) == 1:
element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal = surviving_candidates[0]
if plane_offset_min is not None and plane_offset_min <= FULL_FACE_OFFSET_TOL:
gross_boundary_polygon = space_face_polygon
surviving_candidates[0] = (element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in surviving_candidates:
exterior_boundary_polygon = gross_boundary_polygon
@@ -466,6 +430,102 @@ def auto_generate_boundaries(
return boundaries
def _match_space_face_candidates(
building_elements,
shapes,
element_ngons,
space_matrix,
space_matrix_inv,
space_verts_l,
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
all_filling_ids,
matched_walls_and_columns,
relaxed=False,
):
"""Match all building elements against one space face and select survivors.
When ``relaxed`` is True, sloped element faces that only roughly oppose
the space face are matched using a footprint-scaled distance tolerance.
Relaxed matches keep ``plane_offset_min`` as None so they sort last and
never trigger the single-candidate full-face substitution.
"""
candidates = []
for element in building_elements:
if element.id() in all_filling_ids:
continue
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
continue
match = _match_element_to_space_face(
element,
shapes,
element_ngons,
space_matrix,
space_matrix_inv,
space_verts_l,
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
relaxed=relaxed,
)
if match is None:
continue
dist_min, plane_offset_min, matching_polygons, matched_elem_normal = match
if len(matching_polygons) == 1:
gross_boundary_polygon = matching_polygons[0]
else:
gross_boundary_polygon = shapely.ops.unary_union(matching_polygons)
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid):
continue
if gross_boundary_polygon.is_empty:
continue
candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
# A space face may be matched by several elements within the distance
# tolerance (e.g. a second wall layer or an element end cap). The
# element closest to the face is the actual bounding surface, so
# candidates are kept in order of increasing plane offset (ties broken
# by polygon area). A candidate whose polygon is entirely covered by
# the candidates already kept is redundant and is absorbed into the
# larger boundary. This includes coplanar candidates: e.g. wall end
# caps that are coplanar with the ceiling and fully covered by the
# slab above do not get their own boundary in the reference output.
surviving_candidates = []
kept_union = None
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in sorted(
candidates, key=lambda c: (c[2] if c[2] is not None else float("inf"), -c[3].area)
):
if kept_union is not None and gross_boundary_polygon.difference(kept_union).area < 1e-4:
continue
surviving_candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
kept_union = gross_boundary_polygon if kept_union is None else kept_union.union(gross_boundary_polygon)
# When a single element bounds the space face and its face is (nearly)
# coplanar with it, the boundary covers the full space face (1st level
# semantics) rather than the clipped intersection with the element
# face. This matches the reference output and avoids leaving corner
# slivers to be filled by an extra gap boundary. It only applies to
# strict anti-parallel matches: a relaxed (sloped) face is not coplanar
# with the space face, so plane_offset_min is None and the substitution
# is skipped.
if not relaxed and len(surviving_candidates) == 1:
element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal = surviving_candidates[0]
if plane_offset_min is not None and plane_offset_min <= FULL_FACE_OFFSET_TOL:
gross_boundary_polygon = space_face_polygon
surviving_candidates[0] = (element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
return surviving_candidates
def _match_element_to_space_face(
element,
shapes,
@@ -476,6 +536,7 @@ def _match_element_to_space_face(
space_face_polygon,
face_matrix_inv,
space_face_normal_world,
relaxed=False,
):
"""Match a building element's faces against a single space face.
@@ -520,11 +581,22 @@ def _match_element_to_space_face(
is_parallel = _is_x(angle, 0, tolerance=2)
if not (is_anti_parallel or (is_horizontal_face and is_parallel and is_valid_element)):
continue
if not relaxed or angle < 92:
continue
sv_in_elem = sb.np_apply_matrix(space_centroid[np.newaxis], element_matrix_inv @ space_matrix)[0]
dist = float(np.dot(sv_in_elem - elem_verts_l[0], elem_face_normal_local))
dist_tol = 0.05 if is_horizontal_face else 0.5
if relaxed:
# A sloped face is not coplanar with the space face, so use the
# closest space face vertex and a tolerance scaled to the face
# footprint instead of the centroid-based strict tolerance.
bounds = space_face_polygon.bounds
diagonal = ((bounds[2] - bounds[0]) ** 2 + (bounds[3] - bounds[1]) ** 2) ** 0.5
dist_tol = max(0.5, 0.1 * diagonal)
space_verts_in_elem = sb.np_apply_matrix(space_verts_l, element_matrix_inv @ space_matrix)
dist = float(np.min(np.abs(np.dot(space_verts_in_elem - elem_verts_l[0], elem_face_normal_local))))
else:
sv_in_elem = sb.np_apply_matrix(space_centroid[np.newaxis], element_matrix_inv @ space_matrix)[0]
dist = float(np.dot(sv_in_elem - elem_verts_l[0], elem_face_normal_local))
if abs(dist) > dist_tol:
continue
@@ -560,10 +632,11 @@ def _match_element_to_space_face(
matching_polygons.append(gross_boundary_polygon)
matched_elem_normal = elem_face_normal_world
dist_min = abs(dist) if dist_min is None else min(dist_min, abs(dist))
space_face_normal = _face_normal(space_verts_l)
if space_face_normal is not None:
plane_offset = abs(float(np.dot(space_face_normal, elem_verts_in_space[0] - space_verts_l[0])))
plane_offset_min = plane_offset if plane_offset_min is None else min(plane_offset_min, plane_offset)
if not relaxed:
space_face_normal = _face_normal(space_verts_l)
if space_face_normal is not None:
plane_offset = abs(float(np.dot(space_face_normal, elem_verts_in_space[0] - space_verts_l[0])))
plane_offset_min = plane_offset if plane_offset_min is None else min(plane_offset_min, plane_offset)
if not matching_polygons:
return None
@@ -870,11 +943,29 @@ def _face_normal(verts: np.ndarray) -> Optional[np.ndarray]:
def _face_matrix_from_verts(verts3: np.ndarray) -> np.ndarray:
"""Build a 4x4 face-local coordinate matrix from 3 vertices."""
p1, p2, p3 = verts3[0], verts3[1], verts3[2]
z = sb.np_normal([p1, p2, p3])
x = sb.np_normalized(p2 - p1)
return ifcopenshell.util.placement.a2p(o=p1, z=z, x=x)
"""Build a 4x4 face-local coordinate matrix from a polygon's vertices.
The first three vertices may be collinear in triangulated meshes, so the
normal is computed from the first non-degenerate triple and the X axis is
taken from the first non-degenerate edge.
"""
p1 = np.asarray(verts3[0])
normal = _face_normal(verts3)
if normal is None:
raise ValueError("Cannot build face matrix from a degenerate polygon")
x = np.zeros(3)
for i in range(len(verts3) - 1):
edge = np.asarray(verts3[i + 1]) - np.asarray(verts3[i])
edge_norm = np.linalg.norm(edge)
if edge_norm > 1e-8:
x = edge / edge_norm
break
if np.linalg.norm(x) < 1e-8:
raise ValueError("Cannot find a non-degenerate edge for the face matrix")
return ifcopenshell.util.placement.a2p(o=p1, z=normal, x=x)
def _verts_to_polygon(verts: np.ndarray, face_matrix_inv: np.ndarray, snap: float = 0) -> shapely.Polygon:
@@ -26,11 +26,18 @@ for IFC analysis.
from __future__ import annotations
from typing import Optional, Union
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")
@@ -244,3 +251,377 @@ def get_height_from_wall_tops(
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)
@@ -31,7 +31,7 @@ import ifcopenshell.util.shape
import test.bootstrap
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
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")
@@ -59,7 +59,7 @@ def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
)
direction = ifc_file.createIfcDirection((0.0, 0.0, 1.0))
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",
@@ -266,6 +266,43 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
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])
@@ -330,7 +367,7 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
(440, {1122: 1, 3140: 1, 3214: 1, 3493: 1, 3605: 1, 3640: 1, 3669: 1, 3719: 1}),
(628, {3140: 1, 3838: 1, 3927: 1, 3980: 2, 4033: 1, 4086: 1, 4139: 1, 4199: 1}),
]:
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
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"
@@ -361,7 +398,7 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
ifc_file = ifcopenshell.open(ifc_path)
shapes = _build_shapes_dict_from_iterator(ifc_file)
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
copy = ifcopenshell.file.from_string(ifc_file.to_string())
result = subject.auto_generate_boundaries(
copy, copy.by_id(251), shapes=shapes, boundary_class="IfcRelSpaceBoundary"
)
@@ -384,7 +421,7 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
ifc_file = ifcopenshell.open(ifc_path)
shapes = _build_shapes_dict_from_iterator(ifc_file)
for space_id, expected_total in [(1692, 8), (4356, 8), (4380, 13), (6185, 1)]:
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
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"
)
@@ -396,7 +433,7 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
ifc_file = ifcopenshell.open(ifc_path)
shapes = _build_shapes_dict_from_iterator(ifc_file)
copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string())
copy = ifcopenshell.file.from_string(ifc_file.to_string())
result = subject.auto_generate_boundaries(
copy, copy.by_id(1573), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
)
+160 -2
View File
@@ -22,6 +22,9 @@ 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
@@ -47,7 +50,7 @@ def _build_shapes_dict(ifc_file, elements):
return shapes
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
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")
@@ -75,7 +78,7 @@ def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
)
direction = ifc_file.createIfcDirection((0.0, 0.0, 1.0))
direction = ifc_file.createIfcDirection(direction)
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
rep = ifc_file.create_entity(
"IfcShapeRepresentation",
@@ -184,3 +187,158 @@ class TestGetAutoSpaceHeight(test.bootstrap.IFC4):
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")