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.
This commit is contained in:
CyrilWaechter
2026-07-30 17:11:56 +02:00
parent 8b73de5d42
commit ac23a67e72
5 changed files with 771 additions and 201 deletions
@@ -0,0 +1,409 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Blender-independent IfcRelSpaceBoundary generation from IFC geometry.
These functions operate on IFC geometry data (vertices, faces, edges,
element relationships) without requiring any Blender objects to be loaded.
"""
from __future__ import annotations
import logging
from math import acos, degrees
from typing import Optional, Union
import ifcopenshell
import ifcopenshell.api.boundary
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder as sb
import ifcopenshell.util.unit
import numpy as np
import shapely
import shapely.ops
logger = logging.getLogger("ImportIFC")
BOUNDARY_ELEMENT_CLASSES = ("IfcWall", "IfcColumn", "IfcSlab", "IfcVirtualElement", "IfcCurtainWall")
def auto_generate_boundaries(
ifc_file: ifcopenshell.file,
space: ifcopenshell.entity_instance,
shapes: dict,
boundary_class: str,
boundary_element_classes: tuple = BOUNDARY_ELEMENT_CLASSES,
) -> Union[str, list[ifcopenshell.entity_instance]]:
"""Generate IfcRelSpaceBoundary records from IFC geometry without Blender.
:param ifc_file: The IFC file.
:param space: The IfcSpace entity to generate boundaries for.
:param shapes: Dict ``{element_id: {"verts": ndarray, "faces": ndarray,
"edges": ndarray, "matrix": ndarray}}``. Must include the space itself.
Built by the caller via ``ifcopenshell.geom.iterator``.
:param boundary_class: IFC class for boundaries (e.g.
``"IfcRelSpaceBoundary2ndLevel"``).
:param boundary_element_classes: IFC classes to consider as boundary elements.
:return: List of created ``IfcRelSpaceBoundary`` entities, or error string.
"""
boundaries: list[ifcopenshell.entity_instance] = []
space_shape = shapes.get(space.id())
if space_shape is None:
return "Space geometry not found in shapes dict."
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
# Identify all potential building elements
building_elements = []
for ifc_class in boundary_element_classes:
building_elements.extend(ifc_file.by_type(ifc_class))
# Don't generate boundaries for elements that already have boundaries
for boundary in space.BoundedBy:
if boundary.RelatedBuildingElement in building_elements:
building_elements.remove(boundary.RelatedBuildingElement)
# Filter to elements that have shapes in the cache
building_elements = [e for e in building_elements if e.id() in shapes]
if not building_elements:
return "No building elements found to create boundaries."
# Dissolve space mesh — verts are in local coords, matrix is the placement
space_matrix = space_shape["matrix"]
space_matrix_3x3 = space_matrix[:3, :3]
space_matrix_inv = np.linalg.inv(space_matrix)
# Space verts are already local (get_vertices without use-world-coords)
space_verts_local = space_shape["verts"]
space_ngons = ifcopenshell.util.shape.dissolve_faces(
space_verts_local, space_shape["faces"], space_shape["edges"], merge_coplanar=True
)
# Dissolve building element meshes — verts are in element-local coords
element_ngons = {}
for element in building_elements:
es = shapes[element.id()]
element_ngons[element.id()] = ifcopenshell.util.shape.dissolve_faces(
es["verts"], es["faces"], es["edges"], merge_coplanar=True
)
# Compare space faces and building element faces
for space_ngon in space_ngons:
space_verts_l = space_verts_local[space_ngon]
# Normal from local verts, then transform to world via space placement
space_face_normal_local = _face_normal(space_verts_l)
if space_face_normal_local is None:
continue
space_face_normal_world = space_matrix_3x3 @ space_face_normal_local
for element in building_elements:
element_shape = shapes[element.id()]
element_matrix = element_shape["matrix"]
element_matrix_3x3 = element_matrix[:3, :3]
element_matrix_inv = np.linalg.inv(element_matrix)
for ngon in element_ngons[element.id()]:
elem_verts_l = element_shape["verts"][ngon]
# Normal from local verts, transform to world via element placement
elem_face_normal_local = _face_normal(elem_verts_l)
if elem_face_normal_local is None:
continue
elem_face_normal_world = element_matrix_3x3 @ elem_face_normal_local
# Both normals point outward from their respective solids.
# Adjacent faces have anti-parallel normals (angle ≈ 180°).
# Virtual elements use parallel normals (angle ≈ 0°).
angle = degrees(acos(max(min(float(np.dot(space_face_normal_world, elem_face_normal_world)), 1), -1)))
if _is_x(angle, 180, tolerance=2):
pass
elif element.is_a("IfcVirtualElement") and _is_x(angle, 0, tolerance=2):
pass
else:
continue
# Distance check: transform space vert to element-local, compare to element face
# space-local -> world -> element-local
space_vert_in_elem = sb.np_apply_matrix(space_verts_l[:1], element_matrix_inv @ space_matrix)[0]
dist = float(np.dot(space_vert_in_elem - elem_verts_l[0], elem_face_normal_local))
if abs(dist) > 0.05:
continue
# Build face matrix in space-local coordinates
# (assign_connection_geometry expects location/axes relative to space placement)
face_matrix = _face_matrix_from_verts(space_verts_l[:3])
face_matrix_inv = np.linalg.inv(face_matrix)
# Project space face (already space-local) to 2D
space_face_polygon = _verts_to_polygon(space_verts_l, face_matrix_inv)
if not space_face_polygon.is_valid:
space_face_polygon = space_face_polygon.buffer(0)
# Transform element verts to space-local, then project to 2D
# element-local -> world -> space-local
elem_verts_in_space = sb.np_apply_matrix(elem_verts_l, space_matrix_inv @ element_matrix)
face_polygon = _verts_to_polygon(elem_verts_in_space, face_matrix_inv)
if not face_polygon.is_valid:
face_polygon = face_polygon.buffer(0)
try:
gross_boundary_polygon = space_face_polygon.intersection(face_polygon)
except shapely.errors.GEOSException:
logger.warning(
"Skipping invalid geometry for %s (shapely topology error).",
element.Name or element.is_a(),
exc_info=True,
)
continue
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid):
continue
if gross_boundary_polygon.is_empty:
continue
exterior_boundary_polygon = shapely.Polygon(gross_boundary_polygon.exterior.coords)
# Create parent boundary
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
if element.is_a("IfcVirtualElement"):
parent_boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
else:
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
_set_internal_external(parent_boundary, element)
parent_boundary.RelatingSpace = space
parent_boundary.RelatedBuildingElement = element
_assign_connection_geometry(
ifc_file,
parent_boundary,
exterior_boundary_polygon,
face_matrix,
unit_scale,
)
_set_boundary_name(parent_boundary)
boundaries.append(parent_boundary)
# Process openings
boundaries.extend(
_process_openings(
ifc_file,
element,
elem_face_normal_world,
space_matrix_inv,
element_matrix,
face_matrix,
face_matrix_inv,
exterior_boundary_polygon,
boundary_class,
parent_boundary,
space,
unit_scale,
)
)
return boundaries
def _process_openings(
ifc_file,
building_element,
face_normal_world,
space_matrix_inv,
element_matrix,
face_matrix,
face_matrix_inv,
exterior_boundary_polygon,
boundary_class,
parent_boundary,
space,
unit_scale,
):
"""Process openings and fillings for a building element.
:param face_normal_world: The building element face normal in world space.
:param space_matrix_inv: Inverse of the space placement matrix.
:param element_matrix: The building element placement matrix.
:param face_matrix: The face matrix in space-local coordinates (for connection geometry).
:param face_matrix_inv: The inverse face matrix (for 2D projection).
"""
boundaries = []
for rel in getattr(building_element, "HasOpenings", []):
opening = rel.RelatedOpeningElement
filling = opening.HasFillings[0].RelatedBuildingElement if opening.HasFillings else None
settings = ifcopenshell.geom.settings()
try:
shape = ifcopenshell.geom.create_shape(settings, opening)
except Exception:
continue
opening_verts_l = ifcopenshell.util.shape.get_vertices(shape.geometry)
opening_faces = ifcopenshell.util.shape.get_faces(shape.geometry)
opening_edges = ifcopenshell.util.shape.get_edges(shape.geometry)
opening_matrix = ifcopenshell.util.shape.get_shape_matrix(shape)
opening_matrix_3x3 = opening_matrix[:3, :3]
opening_ngons = ifcopenshell.util.shape.dissolve_faces(
opening_verts_l, opening_faces, opening_edges, merge_coplanar=True
)
opening_polygons = []
for ngon in opening_ngons:
o_verts_l = opening_verts_l[ngon]
# Normal from local verts, transform to world via opening placement
o_normal_local = _face_normal(o_verts_l)
if o_normal_local is None:
continue
o_normal_world = opening_matrix_3x3 @ o_normal_local
angle = degrees(acos(max(min(float(np.dot(o_normal_world, face_normal_world)), 1), -1)))
if not _is_x(angle, 180, tolerance=2):
continue
# Transform opening verts to space-local: opening-local -> world -> space-local
o_verts_in_space = sb.np_apply_matrix(o_verts_l, space_matrix_inv @ opening_matrix)
polygon = _verts_to_polygon(o_verts_in_space, face_matrix_inv)
opening_polygons.append(polygon)
if not opening_polygons:
continue
opening_polygon = shapely.ops.unary_union(opening_polygons)
if opening_polygon.intersection(exterior_boundary_polygon).area == 0:
continue
boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
boundary.RelatingSpace = space
boundary.RelatedBuildingElement = filling or opening
# Use the same space-local face_matrix for connection geometry
_assign_connection_geometry(
ifc_file,
boundary,
opening_polygon,
face_matrix,
unit_scale,
)
if filling:
boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
else:
boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
boundary.InternalOrExternalBoundary = parent_boundary.InternalOrExternalBoundary
if boundary.is_a() != "IfcRelSpaceBoundary":
boundary.ParentBoundary = parent_boundary
_set_boundary_name(boundary)
boundaries.append(boundary)
return boundaries
def _face_normal(verts: np.ndarray) -> Optional[np.ndarray]:
"""Compute the normal of a polygon from its vertices."""
if len(verts) < 3:
return None
return sb.np_normal([verts[0], verts[1], verts[2]])
def _face_matrix_from_verts(verts3: np.ndarray) -> np.ndarray:
"""Build a 4x4 face-local coordinate matrix from 3 vertices."""
p1, p2, p3 = verts3[0], verts3[1], verts3[2]
z = sb.np_normal([p1, p2, p3])
x = sb.np_normalized(p2 - p1)
return ifcopenshell.util.placement.a2p(o=p1, z=z, x=x)
def _verts_to_polygon(verts: np.ndarray, face_matrix_inv: np.ndarray) -> shapely.Polygon:
"""Project 3D vertices onto a 2D plane and create a shapely Polygon."""
verts_2d = sb.np_apply_matrix(verts, face_matrix_inv)[:, :2]
return shapely.Polygon([tuple(v) for v in verts_2d])
def _assign_connection_geometry(
ifc_file: ifcopenshell.file,
boundary: ifcopenshell.entity_instance,
polygon: shapely.Polygon,
face_matrix: np.ndarray,
unit_scale: float,
) -> None:
"""Assign connection geometry to a boundary using the existing API."""
location = face_matrix[:3, 3]
axis = face_matrix[:3, 0]
ref_direction = face_matrix[:3, 2]
outer_boundary = [list(coord) for coord in polygon.exterior.coords[:-1]]
inner_boundaries = [list(interior.coords[:-1]) for interior in polygon.interiors]
ifcopenshell.api.boundary.assign_connection_geometry(
ifc_file,
rel_space_boundary=boundary,
outer_boundary=outer_boundary,
location=location.tolist(),
axis=axis.tolist(),
ref_direction=ref_direction.tolist(),
inner_boundaries=inner_boundaries if inner_boundaries else None,
unit_scale=unit_scale,
)
def _set_internal_external(
boundary: ifcopenshell.entity_instance, building_element: ifcopenshell.entity_instance
) -> None:
"""Set InternalOrExternalBoundary based on element type and psets."""
if building_element.is_a("IfcWall"):
is_external = ifcopenshell.util.element.get_pset(building_element, "Pset_WallCommon", "IsExternal")
if is_external is True:
boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
boundary.InternalOrExternalBoundary = "INTERNAL"
elif building_element.is_a("IfcSlab"):
predefined_type = ifcopenshell.util.element.get_predefined_type(building_element)
if predefined_type == "BASESLAB":
boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH"
else:
is_external = ifcopenshell.util.element.get_pset(building_element, "Pset_SlabCommon", "IsExternal")
if is_external is True:
boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
boundary.InternalOrExternalBoundary = "INTERNAL"
def _set_boundary_name(boundary: ifcopenshell.entity_instance) -> None:
"""Set Name/Description per IFC4x3 convention."""
if boundary.is_a("IfcRelSpaceBoundary2ndLevel"):
boundary.Name = "2ndLevel"
if boundary.CorrespondingBoundary:
boundary.Description = "2a"
else:
boundary.Description = "2b"
elif boundary.is_a("IfcRelSpaceBoundary1stLevel"):
boundary.Name = "1stLevel"
def _is_x(value: float, x: float, tolerance: float = 1e-5) -> bool:
"""Check whether value is within tolerance of x."""
return (x + tolerance) > value > (x - tolerance)
@@ -830,3 +830,190 @@ def bisect_mesh_plane_vf(
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()
for tri_idx in tri_indices:
for e in tri_edges[tri_idx]:
tri_edge_set.add(e)
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
start = next(iter(edge_adjacency))
polygon = [start]
current = edge_adjacency[start]
while current != start:
polygon.append(current)
if current not in edge_adjacency:
break
current = edge_adjacency[current]
result.append(polygon)
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 > 0:
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)