From 3e6460eceb98a486526762cb3f206c57c2c70b80 Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 27 Jul 2026 09:58:24 +0200 Subject: [PATCH] Extract space generation algorithms to ifcopenshell.util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bonsai/bonsai/tool/wall.py | 13 +- .../ifcopenshell/util/element.py | 21 ++ .../ifcopenshell/util/shape.py | 78 ++++++ .../ifcopenshell/util/space.py | 246 ++++++++++++++++++ .../test/util/test_element.py | 44 ++++ .../test/util/test_shape.py | 97 +++++++ .../test/util/test_space.py | 186 +++++++++++++ 7 files changed, 675 insertions(+), 10 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/util/space.py create mode 100644 src/ifcopenshell-python/test/util/test_shape.py create mode 100644 src/ifcopenshell-python/test/util/test_space.py diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py index b3c850e790..4dba3be2e5 100644 --- a/src/bonsai/bonsai/tool/wall.py +++ b/src/bonsai/bonsai/tool/wall.py @@ -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): diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 7dedb449ce..597a1d55fb 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -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 diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape.py b/src/ifcopenshell-python/ifcopenshell/util/shape.py index 7732412861..dc7cea7619 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape.py @@ -752,3 +752,81 @@ 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 diff --git a/src/ifcopenshell-python/ifcopenshell/util/space.py b/src/ifcopenshell-python/ifcopenshell/util/space.py new file mode 100644 index 0000000000..51dc61aa4f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/util/space.py @@ -0,0 +1,246 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# 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 . + +"""Blender-independent utilities for space geometry generation. + +These functions operate on IFC geometry data (vertices, faces, element +relationships) without requiring any Blender objects to be loaded. They are +used by Bonsai's space generation pipeline but can also be used standalone +for IFC analysis. +""" + +from __future__ import annotations + +from typing import Optional, Union + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.shape +import shapely + +BOUNDING_CLASSES = ("IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate") +HEIGHT_DETECTION_CLASSES = ("IfcSlab", "IfcRoof") + + +def get_boundary_lines( + ifc_file: ifcopenshell.file, + shapes: dict, + cut_z: float, + bounding_classes: tuple = BOUNDING_CLASSES, +) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]: + """Generate boundary lines by bisecting IFC element geometry with a horizontal plane. + + :param ifc_file: The IFC file. + :param shapes: Dict of element shapes keyed by element id, as produced by + a geometry cache. Each entry must have ``verts`` (n,3 ndarray), + ``faces`` (m,3 ndarray), ``bottom_z`` (float), ``top_z`` (float). + :param cut_z: Z elevation of the cutting plane in world coordinates. + :param bounding_classes: IFC classes to treat as space-bounding elements. + :return: ``(boundary_lines, bounding_elements)`` where boundary_lines is a + list of shapely LineString segments and bounding_elements is a list of + IFC entity instances that intersect the cutting plane. + """ + boundary_lines: list[shapely.LineString] = [] + bounding_elements: list[ifcopenshell.entity_instance] = [] + + for element_id, shape_data in shapes.items(): + element = ifc_file.by_id(element_id) + if not any(element.is_a(cls) for cls in bounding_classes): + continue + if cut_z <= shape_data["bottom_z"] or cut_z >= shape_data["top_z"]: + continue + bounding_elements.append(element) + segments = ifcopenshell.util.shape.bisect_mesh_plane_vf( + shape_data["verts"], shape_data["faces"], cut_z, precision=3, extend=0.05 + ) + for start, end in segments: + boundary_lines.append(shapely.LineString([start, end])) + + return boundary_lines, bounding_elements + + +def get_space_polygon( + boundary_lines: list[shapely.LineString], + x: float, + y: float, +) -> tuple[Union[shapely.Polygon, str], list]: + """Assemble boundary lines into closed polygons and find the one containing (x, y). + + :param boundary_lines: List of shapely LineString segments forming a planar graph. + :param x: X coordinate of the point to test. + :param y: Y coordinate of the point to test. + :return: ``(polygon, [])`` on success, or ``("NO POLYGONS FOUND", [])`` / + ``("NO POLYGON FOR POINT", [])`` on failure. The second element is + reserved for bounding elements (returned by the caller from + :func:`get_boundary_lines`). + """ + unioned = shapely.union_all(shapely.GeometryCollection(boundary_lines)) + closed_polygons = shapely.polygonize(unioned.geoms) + if not closed_polygons: + return "NO POLYGONS FOUND", [] + for polygon in closed_polygons.geoms: + if shapely.contains_xy(polygon, x, y): + return shapely.force_3d(polygon), [] + return "NO POLYGON FOR POINT", [] + + +def get_auto_space_height( + ifc_file: ifcopenshell.file, + shapes: dict, + space_polygon: shapely.Polygon, + base_z: float, + bounding_walls: list[ifcopenshell.entity_instance], +) -> Optional[float]: + """Auto-detect space height from elements above using IFC geometry. + + Detection priority: + 1. ``IfcRelConnectsElements`` (TOP) connections on bounding walls + 2. ``IfcSlab`` / ``IfcRoof`` elements above with XY overlap to the space polygon + 3. Minimum wall top Z of bounding walls + + :param ifc_file: The IFC file. + :param shapes: Dict of element shapes keyed by element id (see :func:`get_boundary_lines`). + :param space_polygon: The space footprint polygon in world XY. + :param base_z: The space's base Z in world coordinates. + :param bounding_walls: List of IFC wall elements bounding the space. + :return: Detected height in meters, or ``None`` if nothing found. + """ + height = get_height_from_top_connections(ifc_file, shapes, bounding_walls, base_z, space_polygon) + if height is not None and height > 0: + return height + + height = get_height_from_elements_above(ifc_file, shapes, space_polygon, base_z) + if height is not None and height > 0: + return height + + height = get_height_from_wall_tops(shapes, bounding_walls, base_z) + if height is not None and height > 0: + return height + + return None + + +def get_height_from_top_connections( + ifc_file: ifcopenshell.file, + shapes: dict, + bounding_walls: list[ifcopenshell.entity_instance], + base_z: float, + space_polygon: shapely.Polygon, +) -> Optional[float]: + """Find the lowest bottom face of elements connected to bounding walls via IfcRelConnectsElements(TOP). + + :param ifc_file: The IFC file. + :param shapes: Dict of element shapes keyed by element id. + :param bounding_walls: List of IFC wall elements. + :param base_z: The space's base Z in world coordinates. + :param space_polygon: The space footprint polygon in world XY. + :return: Height in meters, or ``None``. + """ + lowest_min_z: Optional[float] = None + for wall_element in bounding_walls: + for connected_element, _rel in ifcopenshell.util.element.iter_top_connections(wall_element): + if not (connected_element.is_a("IfcSlab") or connected_element.is_a("IfcRoof")): + continue + shape_data = shapes.get(connected_element.id()) + if not shape_data: + continue + min_z = shape_data["bottom_z"] + if min_z <= base_z: + continue + verts = shape_data["verts"] + element_box = shapely.box( + float(verts[:, 0].min()), + float(verts[:, 1].min()), + float(verts[:, 0].max()), + float(verts[:, 1].max()), + ) + if not element_box.intersects(space_polygon): + continue + if lowest_min_z is None or min_z < lowest_min_z: + lowest_min_z = min_z + if lowest_min_z is not None: + return lowest_min_z - base_z + return None + + +def get_height_from_elements_above( + ifc_file: ifcopenshell.file, + shapes: dict, + space_polygon: shapely.Polygon, + base_z: float, + height_classes: tuple = HEIGHT_DETECTION_CLASSES, +) -> Optional[float]: + """Find the lowest IfcSlab / IfcRoof above whose XY bbox overlaps the space polygon. + + :param ifc_file: The IFC file. + :param shapes: Dict of element shapes keyed by element id. + :param space_polygon: The space footprint polygon in world XY. + :param base_z: The space's base Z in world coordinates. + :param height_classes: IFC classes to consider as ceiling elements. + :return: Height in meters, or ``None``. + """ + lowest_min_z: Optional[float] = None + for ifc_class in height_classes: + for element in ifc_file.by_type(ifc_class): + shape_data = shapes.get(element.id()) + if not shape_data: + continue + min_z = shape_data["bottom_z"] + if min_z <= base_z: + continue + verts = shape_data["verts"] + element_box = shapely.box( + float(verts[:, 0].min()), + float(verts[:, 1].min()), + float(verts[:, 0].max()), + float(verts[:, 1].max()), + ) + if not element_box.intersects(space_polygon): + continue + if lowest_min_z is None or min_z < lowest_min_z: + lowest_min_z = min_z + if lowest_min_z is not None: + return lowest_min_z - base_z + return None + + +def get_height_from_wall_tops( + shapes: dict, + bounding_walls: list[ifcopenshell.entity_instance], + base_z: float, +) -> Optional[float]: + """Find the minimum wall top Z among bounding walls. + + :param shapes: Dict of element shapes keyed by element id. + :param bounding_walls: List of IFC wall elements. + :param base_z: The space's base Z in world coordinates. + :return: Height in meters, or ``None``. + """ + lowest_top_z: Optional[float] = None + for wall_element in bounding_walls: + shape_data = shapes.get(wall_element.id()) + if not shape_data: + continue + max_z = shape_data["top_z"] + if max_z <= base_z: + continue + if lowest_top_z is None or max_z < lowest_top_z: + lowest_top_z = max_z + if lowest_top_z is not None: + return lowest_top_z - base_z + return None diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 9ec355f7fe..013d4f6a64 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -1393,3 +1393,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)) == [] diff --git a/src/ifcopenshell-python/test/util/test_shape.py b/src/ifcopenshell-python/test/util/test_shape.py new file mode 100644 index 0000000000..364926c923 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_shape.py @@ -0,0 +1,97 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# 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 . + +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 diff --git a/src/ifcopenshell-python/test/util/test_space.py b/src/ifcopenshell-python/test/util/test_space.py new file mode 100644 index 0000000000..827d4da592 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_space.py @@ -0,0 +1,186 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# 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 . + +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.geom +import ifcopenshell.guid +import ifcopenshell.util.shape +import ifcopenshell.util.space as subject +import pytest +import shapely +import test.bootstrap + + +def _build_shapes_dict(ifc_file, elements): + """Build a shapes dict as expected by ifcopenshell.util.space functions.""" + settings = ifcopenshell.geom.settings() + settings.set("disable-opening-subtractions", True) + settings.set("use-world-coords", True) + shapes = {} + for element in elements: + shape = ifcopenshell.geom.create_shape(settings, element) + verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry) + faces = ifcopenshell.util.shape.get_faces(shape.geometry) + zs = verts[:, 2] + shapes[element.id()] = { + "verts": verts, + "faces": faces, + "bottom_z": float(zs.min()), + "top_z": float(zs.max()), + } + return shapes + + +def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0): + """Add a body representation (extruded polyline) to an element.""" + if not ifc_file.by_type("IfcProject"): + ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject") + ctx = ifc_file.createIfcGeometricRepresentationContext( + ContextType="Model", + CoordinateSpaceDimension=3, + Precision=1e-5, + WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D( + ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + ifc_file.createIfcDirection((1.0, 0.0, 0.0)), + ), + ) + sub_ctx = ifc_file.createIfcGeometricRepresentationSubContext( + ContextIdentifier="Body", + ContextType="Model", + ParentContext=ctx, + TargetView="MODEL_VIEW", + ) + pts = [ifc_file.createIfcCartesianPoint((float(x), float(y))) for x, y in coords_2d] + polyline = ifc_file.createIfcPolyline(pts) + profile = ifc_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="CURVE", OuterCurve=polyline) + placement = ifc_file.createIfcAxis2Placement3D( + ifc_file.createIfcCartesianPoint((0.0, 0.0, z_offset)), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + ifc_file.createIfcDirection((1.0, 0.0, 0.0)), + ) + direction = ifc_file.createIfcDirection((0.0, 0.0, 1.0)) + solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth) + rep = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=sub_ctx, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[solid], + ) + ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=rep) + + +class TestGetBoundaryLines(test.bootstrap.IFC4): + def test_returns_segments_for_intersecting_walls(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + _add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0) + shapes = _build_shapes_dict(self.file, [wall]) + lines, bounding = subject.get_boundary_lines(self.file, shapes, cut_z=1.0) + assert len(lines) > 0 + assert wall in bounding + + def test_skips_elements_not_intersecting_plane(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + _add_extruded_body(self.file, wall, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 1.0) + shapes = _build_shapes_dict(self.file, [wall]) + lines, bounding = subject.get_boundary_lines(self.file, shapes, cut_z=10.0) + assert lines == [] + assert bounding == [] + + def test_skips_non_bounding_classes(self): + slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab") + _add_extruded_body(self.file, slab, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 1.0) + shapes = _build_shapes_dict(self.file, [slab]) + lines, bounding = subject.get_boundary_lines(self.file, shapes, cut_z=0.5) + assert slab not in bounding + + +class TestGetSpacePolygon(test.bootstrap.IFC4): + def test_finds_containing_polygon(self): + lines = [ + shapely.LineString([(0, 0), (10, 0)]), + shapely.LineString([(10, 0), (10, 10)]), + shapely.LineString([(10, 10), (0, 10)]), + shapely.LineString([(0, 10), (0, 0)]), + ] + polygon, _ = subject.get_space_polygon(lines, 5, 5) + assert not isinstance(polygon, str) + assert polygon.area == pytest.approx(100) + + def test_no_polygons_found(self): + polygon, _ = subject.get_space_polygon([], 0, 0) + assert polygon == "NO POLYGONS FOUND" + + def test_no_polygon_for_point(self): + lines = [ + shapely.LineString([(0, 0), (10, 0)]), + shapely.LineString([(10, 0), (10, 10)]), + shapely.LineString([(10, 10), (0, 10)]), + shapely.LineString([(0, 10), (0, 0)]), + ] + polygon, _ = subject.get_space_polygon(lines, 50, 50) + assert polygon == "NO POLYGON FOR POINT" + + +class TestGetAutoSpaceHeight(test.bootstrap.IFC4): + def test_height_from_top_connection(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab") + _add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0) + _add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0) + self.file.createIfcRelConnectsElements( + GlobalId=ifcopenshell.guid.new(), + RelatingElement=slab, + RelatedElement=wall, + Description="TOP", + ) + shapes = _build_shapes_dict(self.file, [wall, slab]) + space_polygon = shapely.box(-5, -5, 5, 5) + height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [wall]) + assert height is not None + assert height == pytest.approx(3.0, abs=0.1) + + def test_height_from_elements_above_without_top_connection(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab") + _add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0) + _add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0) + shapes = _build_shapes_dict(self.file, [wall, slab]) + space_polygon = shapely.box(-5, -5, 5, 5) + height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [wall]) + assert height is not None + assert height == pytest.approx(3.0, abs=0.1) + + def test_height_from_wall_tops_when_no_slab(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + _add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0) + shapes = _build_shapes_dict(self.file, [wall]) + space_polygon = shapely.box(-5, -5, 5, 5) + height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [wall]) + assert height is not None + assert height == pytest.approx(3.0, abs=0.1) + + def test_returns_none_when_no_elements_above(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + _add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0) + shapes = _build_shapes_dict(self.file, [wall]) + space_polygon = shapely.box(-100, -100, -90, -90) + height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, []) + assert height is None