mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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.
This commit is contained in:
@@ -26,11 +26,12 @@ for IFC analysis.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Union
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.shape
|
||||
import numpy as np
|
||||
import shapely
|
||||
|
||||
BOUNDING_CLASSES = ("IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate")
|
||||
@@ -244,3 +245,101 @@ def get_height_from_wall_tops(
|
||||
if lowest_top_z is not None:
|
||||
return lowest_top_z - base_z
|
||||
return None
|
||||
|
||||
|
||||
def _nearest_ray_hits(
|
||||
tree: ifcopenshell.geom.tree,
|
||||
origins: list[tuple[float, float, float]],
|
||||
ray_dir: np.ndarray,
|
||||
) -> list[ifcopenshell.geom.hit]:
|
||||
"""Nearest hit per origin; select_ray returns all hits including duplicates."""
|
||||
hits = []
|
||||
for origin in origins:
|
||||
results = sorted(tree.select_ray(origin, ray_dir, length=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 "EXTRUDE_CLIP" when hits were
|
||||
found, otherwise "BREP". 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_offsets = [(0.0, 0.0)]
|
||||
if bounds[2] - bounds[0] > 0.1:
|
||||
sample_offsets.append((0.25 * (bounds[2] - bounds[0]), 0.0))
|
||||
sample_offsets.append((-0.25 * (bounds[2] - bounds[0]), 0.0))
|
||||
if bounds[3] - bounds[1] > 0.1:
|
||||
sample_offsets.append((0.0, 0.25 * (bounds[3] - bounds[1])))
|
||||
sample_offsets.append((0.0, -0.25 * (bounds[3] - bounds[1])))
|
||||
|
||||
hits = _nearest_ray_hits(tree, [(cx + dx, cy + dy, origin_z) for dx, dy in sample_offsets], 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
|
||||
|
||||
@@ -22,6 +22,7 @@ import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.space as subject
|
||||
import numpy as np
|
||||
import pytest
|
||||
import shapely
|
||||
import test.bootstrap
|
||||
@@ -184,3 +185,39 @@ class TestGetAutoSpaceHeight(test.bootstrap.IFC4):
|
||||
space_polygon = shapely.box(-100, -100, -90, -90)
|
||||
height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [])
|
||||
assert height is None
|
||||
|
||||
|
||||
class TestGetVerticalBoundingPlanes(test.bootstrap.IFC4):
|
||||
def test_flat_ceiling_returns_single_plane(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
strategy, planes = subject.get_vertical_bounding_planes(
|
||||
self.file, shapes, tree, space_polygon, base_z=0.0, direction="UP"
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(planes) == 1
|
||||
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
|
||||
assert np.allclose(planes[0][1], [0.0, 0.0, 1.0], atol=0.01)
|
||||
|
||||
def test_flat_ceiling_returns_single_plane_from_rl_origin(self):
|
||||
# Same result when rays are cast from an RL cut elevation (z=1.0)
|
||||
# instead of the default base_z + 0.001.
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
strategy, planes = subject.get_vertical_bounding_planes(
|
||||
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), base_z=0.0, direction="UP", start_z=1.0
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(planes) == 1
|
||||
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
|
||||
|
||||
Reference in New Issue
Block a user