mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 14:13:41 +00:00
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.
This commit is contained in:
@@ -245,16 +245,9 @@ class Wall(bonsai.core.tool.Wall):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
|
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
|
||||||
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
|
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
|
||||||
connecting a slab to this wall — the rel kind ``extend_walls_to_underside``
|
connecting a slab to this wall. Delegates to
|
||||||
creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
|
:func:`ifcopenshell.util.element.iter_top_connections`."""
|
||||||
side of the TOP rel."""
|
yield from ifcopenshell.util.element.iter_top_connections(wall)
|
||||||
for rel in getattr(wall, "ConnectedFrom", []) or ():
|
|
||||||
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
|
|
||||||
continue
|
|
||||||
slab = rel.RelatingElement
|
|
||||||
if slab is None:
|
|
||||||
continue
|
|
||||||
yield slab, rel
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
|
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
|
||||||
|
|||||||
@@ -2007,3 +2007,24 @@ def get_material_profiles(element: ifcopenshell.entity_instance) -> list[Priorit
|
|||||||
)
|
)
|
||||||
for material_profile in material.MaterialProfiles
|
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,81 @@ def get_total_edge_length(geometry: W.triangulation) -> float:
|
|||||||
vertices = get_vertices(geometry)
|
vertices = get_vertices(geometry)
|
||||||
vertices = vertices[get_edges(geometry)]
|
vertices = vertices[get_edges(geometry)]
|
||||||
return np.linalg.norm(vertices[:, 1] - vertices[:, 0], axis=1).sum().item()
|
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
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# 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 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
|
||||||
@@ -1392,3 +1392,47 @@ class TestCopyDeepIFC4(test.bootstrap.IFC4):
|
|||||||
element2 = subject.copy_deep(self.file, element)
|
element2 = subject.copy_deep(self.file, element)
|
||||||
assert element2.Segments[0][0] == (1, 2)
|
assert element2.Segments[0][0] == (1, 2)
|
||||||
assert element2.Segments[1][0] == (3, 4)
|
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,97 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 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 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
|
||||||
Reference in New Issue
Block a user