Compare commits

...

39 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
CyrilWaechter 243f13de09 Optimize coplanar face reconstruction and add tests
Vectorize the coplanarity prefilter in _union_coplanar_face_polygon and
compute per-triangle normals once instead of per space face. Add
regression tests for the SmallHouse and Triangle boundary test models.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 0d5c9a0ce2 Keep shaft holes in generated space boundaries
dissolve_faces with merge_coplanar drops interior rings, so the shaft
opening in a ceiling was lost and replaced by spurious wall-cap
boundaries. Reconstruct the space face from its raw coplanar triangles,
preserve interior rings in the assigned boundary, absorb redundant
candidates by plane offset, and raise the full-face tolerance so walls
offset by their half thickness get a single boundary.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter ea6f03409f Fix space boundary generation regressions
When several elements match the same space face, offset matches that only
duplicate coplanar coverage are now skipped, and a single bounding element
within a small plane offset gets the full space face instead of a clipped
polygon. Existing boundaries are removed before regeneration so stale 2nd
level boundaries are not left behind, and the Bonsai operator delegates
element filtering to auto_generate_boundaries.

Regenerates SmallHouse boundaries to match the reference output and keeps
the ExternalEarth opening unioning intact.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 21b4cd2403 Deduplicate opening boundaries in auto_generate_boundaries
When a building element has multiple ngons matching the same space face,
_process_openings was called multiple times for the same opening/filling,
producing duplicate boundaries (e.g. two boundaries for the same door).

Fix: pass a set of processed filling IDs to _process_openings and skip
already-processed openings.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter a543022bba Fix dissolve_faces polygon reconstruction with merge_coplanar
When merge_coplanar merges two sub-faces that share an edge from the
original BRep (e.g. two rectangles forming an L-shape cap), that shared
edge remained in boundary_edges via original_edges filtering, causing
the edge_adjacency walk to produce wrong polygons.

Fix: after coplanar merging, use edge frequency (edges used by exactly
1 triangle = boundary) instead of original_edges filtering, which
correctly identifies only outer boundary edges.

Also add safety checks: edge_adjacency emptiness guard, infinite loop
protection, and minimum polygon length check.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 6f9d5c4005 Fix axis/ref_direction swap in connection geometry
The a2p placement matrix stores col[0]=X (edge direction) and
col[2]=Z (face normal), but assign_connection_geometry expects
axis=Z (normal) and ref_direction=X (edge).

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter a7b6c66f77 Extract boundary generation to ifcopenshell.util.boundary
Move Blender-independent boundary generation algorithm from Bonsai
(GPL) to ifcopenshell.util.boundary (LGPL):

- ifcopenshell.util.shape.dissolve_faces: reconstruct polygonal faces
  from triangulated mesh using original edges from get_edges() + Union-Find
- ifcopenshell.util.boundary.auto_generate_boundaries: full boundary
  generation algorithm using IFC geometry (numpy, shapely) without
  Blender — replaces bmesh, matrix_world, tool.Cad.is_x, mathutils with
  numpy equivalents
- Uses existing ifcopenshell.api.boundary.assign_connection_geometry
  for connection geometry creation
- Uses existing ifcopenshell.util.placement.a2p + np_normal for face
  matrix construction
- BOUNDARY_ELEMENT_CLASSES expanded to include IfcColumn and
  IfcCurtainWall

Bonsai's boundary/operator.py auto_generate_boundaries is now a thin
adapter handling Blender-specific preprocessing (flushing moved
objects, building iterator + tree) then delegating to the util module.

Added 12 tests: 3 for dissolve_faces, 3 for auto_generate_boundaries.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 6dc671f24d Fix shapely topology crash in boundary generation
Add buffer(0) validation for space_face_polygon and face_polygon
before intersection, following the same pattern as tool/cad.py.

Wrap the intersection in try/except for shapely.errors.GEOSException
to catch remaining topology errors. On exception, set
bonsai.last_error (so the 'Copy Error Message To Clipboard' button
appears in the UI), report an ERROR to the operator, and continue
processing other face pairs instead of crashing.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 9b28444255 Fix space regen Z placement for centered representations
get_x_y_z_h_mat_from_obj computes z from bound_box[0], which differs from obj.location.z when the representation is centered at origin (e.g., a PolygonalFaceSet unit cube). The previous fix (24f7629f) removed translate_obj_to_z_location to prevent doubling Z for extrusion representations, but that broke centered ones where z != location.z.

Replace the removed relative translate with an absolute active_obj.location.z = z. This is a no-op for extrusion representations (z == location.z) and corrects the position for centered ones.

Add test_regenerate_space_from_centered_cube_representation to cover the regeneration path with a centered mesh representation.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 7c04a0d533 Fix pyright possibly-missing-submodule in covering test
Add explicit import bonsai.core.tool to satisfy pyright's type checker,
which requires submodules to be explicitly imported rather than relying
on transitive imports from import bonsai.
2026-08-17 23:03:42 +02:00
CyrilWaechter 02126a8d82 Fix ruff import-ordering in covering and spatial tests
ruff check flagged unsorted imports in test/core/test_covering.py
and test/tool/test_spatial.py. Fix by reorganising import blocks.
2026-08-17 23:03:42 +02:00
CyrilWaechter c9fcabef65 Rework TestGenerateSpace to use IFC representations instead of Blender cubes
All 8 space-generation tests now create IFC walls/slabs with real
solid-block representations using IfcExtrudedAreaSolid, instead of
relying on the old Blender-mesh bisection path (broken since 79ee88da5
switched to IFC-geometry-only for boundary detection).

- _BlockHelper provides create_wall (10x10xheight block) and create_slab
  (12x12x1.0 block) helpers using standard IfcOpenShell API calls.
- The wall block bisects to a 10x10 polygon at the cutting plane
  (matching the old cube-behaviour), and auto-height detects wall_top_z.
- Pre-existing height assertions (z=10) now pass correctly because
  auto-height = wall_top_z - base_z = 10 - 0 = 10 (the old values were
  wrong for the Blender path where h defaulted to 3).
- test_regenerate_after_wall_height_change modifies the IFC extrusion
  depth directly and bumps the geom cache token via
  _bump_geom_cache_token() instead of relying on Blender depsgraph.
- No Blender cubes are created except when absolutely needed for
  selection/active-object flow (regeneration, apply-height).
- Added ifcopenshell.util.representation to imports.
- Import _bump_geom_cache_token from bonsai.tool.spatial.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter 99c89c3f44 Add covering core tests verifying tuple-unpack fix
Covers all three covering operators with success and error-path tests
using the Prophecy mocking framework. The key assertion verifies that
get_space_polygon_from_context_visible_objects' return value is unpacked
so the polygon (not the tuple of polygon+bounding_elements) reaches
set_covering_representation_from_polygon.

Shapely geometry objects are not JSON-serialisable (Prophecy call
serialisation), so we use the plain integer 42 as a stand-in for the
polygon value.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter b78396051d Fix covering operators to unpack tuple return from get_space_polygon_from_context_visible_objects
Three covering core functions (add_instance_flooring_covering_from_cursor,
add_instance_ceiling_covering_from_cursor, regen_selected_covering_object)
used the old single-value assignment from
get_space_polygon_from_context_visible_objects, which now returns a
(polygon, bounding_walls) tuple. The isinstance(str) guard never fired,
causing the tuple to flow into set_covering_representation_from_polygon
and raise a shapely error.

Fix by unpacking space_polygon, _ at all three call sites.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter 6715e684a8 Extract space generation algorithms to ifcopenshell.util
Move Blender-independent space generation algorithms from Bonsai
(GPL) to ifcopenshell.util (LGPL):

- ifcopenshell.util.shape.bisect_mesh_plane_vf: vectorized numpy
  triangle/plane intersection for mesh bisection
- ifcopenshell.util.element.iter_top_connections: walker for
  IfcRelConnectsElements(TOP) relationships
- ifcopenshell.util.space: new module with get_boundary_lines,
  get_space_polygon, get_auto_space_height and height detection
  helpers — all operating on IFC geometry without Blender

Bonsai's tool/spatial.py now delegates to these utilities via
thin wrappers, keeping only Blender-specific concerns (cache
management with depsgraph invalidation, UI property reads).

tool/wall.py iter_wall_slab_connections delegates to
ifcopenshell.util.element.iter_top_connections.

Added 22 tests: 6 for bisect_mesh_plane_vf, 10 for space
generation algorithms, 4 for iter_top_connections, 2 Bonsai
integration tests for cache behavior.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter 1695571256 Add auto-detect space height from elements above
Space height is now auto-detected using IFC geometry directly
(ifcopenshell.geom.create_shape + get_shape_bottom/top_elevation)
instead of Blender object bounding boxes. This fixes height detection
when the slab above is not loaded in Blender.

Detection priority:
1. IfcRelConnectsElements(TOP) connections on bounding walls
2. IfcSlab / IfcRoof elements above with XY overlap to space polygon
3. Minimum wall top Z of bounding walls
4. Fallback to space_height property (default 3m)

Added space_height and force_space_height properties to
BIMSpatialDecompositionProperties. The height field is synced to
the active space's height via active_object_callback (msgbus), not
in draw().

Added ApplySpaceHeightToSelection operator to modify
IfcExtrudedAreaSolid.Depth in place without regenerating footprint.

bounding_walls changed from list[tuple[element, obj]] to
list[entity_instance] since Blender objects are no longer needed.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:41 +02:00
CyrilWaechter 4f0e572e0f Add copy attribute to selection for boundaries
Add a paste button to IfcRelSpaceBoundary specific attributes
(RelatingSpace, RelatedBuildingElement, ParentBoundary,
CorrespondingBoundary, PhysicalOrVirtualBoundary,
InternalOrExternalBoundary) reusing the existing
copy_attribute_to_selection core function.

The core function value type hint is broadened from Union[str, None]
to Any since boundary relation attributes pass IFC entity instances.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:41 +02:00
28 changed files with 4384 additions and 261 deletions
+3
View File
@@ -17,3 +17,6 @@
[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
@@ -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.
+19
View File
@@ -59,6 +59,7 @@ 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()
@@ -121,9 +122,25 @@ 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
@@ -528,6 +545,7 @@ 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)
@@ -570,6 +588,7 @@ def _install_viewport_overlays() -> None:
ArrayPreviewDecorator.install(bpy.context)
finally:
install_decorator_cache_handlers()
install_geom_cache_handlers()
@persistent
@@ -23,6 +23,7 @@ from . import operator, prop, ui
classes = (
operator.AddBoundary,
operator.ColourByRelatedBuildingElement,
operator.CopyBoundaryAttributeToSelection,
operator.DecorateBoundaries,
operator.DisableEditingBoundary,
operator.DisableEditingBoundaryGeometry,
+51 -195
View File
@@ -18,7 +18,7 @@
import logging
import multiprocessing
from math import acos, degrees, inf, pi, radians
from math import inf, pi
from typing import Optional, Union
import bmesh
@@ -28,6 +28,7 @@ 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
@@ -39,6 +40,7 @@ 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
@@ -422,6 +424,32 @@ 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"
@@ -668,36 +696,30 @@ 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]]:
"""
:return: list of created boundaries or a string with error description.
"""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.
"""
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
# 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")
)
building_elements = []
for ifc_class in ifcopenshell.util.boundary.BOUNDARY_ELEMENT_CLASSES:
building_elements.extend(ifc_file.by_type(ifc_class))
# 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)
# 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
# Build shapes dict with iterator (parallel, includes space + building elements)
include = building_elements + [space]
tree = ifcopenshell.geom.tree()
shapes = {}
@@ -712,189 +734,23 @@ 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
# 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]
# 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()]
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
return ifcopenshell.util.boundary.auto_generate_boundaries(
ifc_file, space, filtered_shapes, props.boundary_class
)
def create_element_boundary(
self,
+8 -2
View File
@@ -77,10 +77,14 @@ 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()
row = self.layout.row(align=True)
row.prop(self.bprops, "physical_or_virtual")
row = self.layout.row()
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
op.name = "PhysicalOrVirtualBoundary"
row = self.layout.row(align=True)
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")
@@ -125,6 +129,8 @@ 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,6 +178,7 @@ classes = (
covering.RegenSelectedCoveringObject,
space.ToggleSpaceVisibility,
space.ToggleHideSpaces,
space.ApplySpaceHeightToSelection,
mep.FitFlowSegments,
mep.RegenerateDistributionElement,
prop.SnapMousePoint,
@@ -18,7 +18,9 @@
import bpy
import ifcopenshell.util.unit
import bonsai.core.geometry as core_geometry
import bonsai.core.spatial as core
import bonsai.tool as tool
@@ -115,3 +117,47 @@ 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,6 +24,7 @@ from bpy.props import (
BoolProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
IntProperty,
PointerProperty,
StringProperty,
@@ -277,6 +278,17 @@ 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
@@ -294,6 +306,8 @@ 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,9 +83,14 @@ 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, Union
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -31,7 +31,7 @@ def copy_attribute_to_selection(
root: type[tool.Root],
spatial: type[tool.Spatial],
name: str,
value: Union[str, None],
value: Any,
) -> 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
+46 -7
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 = 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":
@@ -220,8 +226,25 @@ 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"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
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,
)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
@@ -234,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)
@@ -248,11 +273,25 @@ 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[-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.
+368 -25
View File
@@ -19,6 +19,7 @@
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
@@ -34,11 +35,14 @@ 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
@@ -58,8 +62,52 @@ 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
@@ -755,29 +803,233 @@ 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 ["IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate"]:
for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES:
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
) -> 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
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,
)
@classmethod
def debug_shape(cls, foo: shapely.Polygon) -> None:
@@ -810,7 +1062,9 @@ class Spatial(bonsai.core.tool.Spatial):
bpy.context.view_layer.update()
@classmethod
def get_boundary_lines_from_context_visible_objects(cls) -> list[shapely.LineString]:
def get_boundary_lines_from_context_visible_objects(
cls,
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
props = tool.Model.get_model_props()
calculation_rl = props.rl3
container = tool.Root.get_default_container()
@@ -818,6 +1072,7 @@ 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)
@@ -831,6 +1086,7 @@ 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:
@@ -870,7 +1126,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
return boundary_lines, bounding_elements
@classmethod
def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh:
@@ -1086,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)
@@ -1111,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:
+3 -10
View File
@@ -245,16 +245,9 @@ 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 — 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
connecting a slab to this wall. Delegates to
:func:`ifcopenshell.util.element.iter_top_connections`."""
yield from ifcopenshell.util.element.iter_top_connections(wall)
@classmethod
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
+112
View File
@@ -0,0 +1,112 @@
# 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,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)"
+546 -16
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
@@ -24,12 +26,16 @@ import ifcopenshell.api.feature
import ifcopenshell.api.nest
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.util.representation
import numpy as np
from mathutils import Matrix
import pytest
import shapely
from mathutils import Matrix, Vector
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
@@ -258,17 +264,59 @@ 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()
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)
# 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)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
@@ -292,13 +340,8 @@ class TestGenerateSpace(NewFile):
def test_regenerate_space_preserves_z_location(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
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)
_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"]
@@ -307,8 +350,495 @@ 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)
File diff suppressed because it is too large Load Diff
@@ -2007,3 +2007,24 @@ 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,3 +752,279 @@ 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)
@@ -0,0 +1,627 @@
# 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)
@@ -0,0 +1,440 @@
# 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,3 +1392,47 @@ 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)) == []
@@ -0,0 +1,139 @@
# 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) == []
@@ -0,0 +1,344 @@
# 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")