mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
Match sloped faces in boundary generation
Allow sloped roof/slab/wall faces to bound space faces when the strict anti-parallel rule leaves a face uncovered, using a footprint-scaled distance tolerance. Existing matching behaviour is preserved (fallback-only). Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -281,14 +281,27 @@ def auto_generate_boundaries(
|
||||
space_face_normals_world[space_ngon_idx] = space_face_normal_world
|
||||
covered_by_face[space_ngon_idx] = []
|
||||
|
||||
candidates = []
|
||||
for element in building_elements:
|
||||
if element.id() in all_filling_ids:
|
||||
continue
|
||||
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
|
||||
continue
|
||||
match = _match_element_to_space_face(
|
||||
element,
|
||||
surviving_candidates = _match_space_face_candidates(
|
||||
building_elements,
|
||||
shapes,
|
||||
element_ngons,
|
||||
space_matrix,
|
||||
space_matrix_inv,
|
||||
space_verts_l,
|
||||
space_face_polygon,
|
||||
face_matrix_inv,
|
||||
space_face_normal_world,
|
||||
all_filling_ids,
|
||||
matched_walls_and_columns,
|
||||
)
|
||||
|
||||
# A sloped roof/slab/wall face is not anti-parallel to the space face
|
||||
# it bounds, so it fails the strict gate above and leaves the face
|
||||
# uncovered. Run a relaxed matching pass as a fallback so that such
|
||||
# faces still generate a boundary.
|
||||
if not surviving_candidates:
|
||||
surviving_candidates = _match_space_face_candidates(
|
||||
building_elements,
|
||||
shapes,
|
||||
element_ngons,
|
||||
space_matrix,
|
||||
@@ -297,59 +310,10 @@ def auto_generate_boundaries(
|
||||
space_face_polygon,
|
||||
face_matrix_inv,
|
||||
space_face_normal_world,
|
||||
all_filling_ids,
|
||||
matched_walls_and_columns,
|
||||
relaxed=True,
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
dist_min, plane_offset_min, matching_polygons, matched_elem_normal = match
|
||||
|
||||
if len(matching_polygons) == 1:
|
||||
gross_boundary_polygon = matching_polygons[0]
|
||||
else:
|
||||
gross_boundary_polygon = shapely.ops.unary_union(matching_polygons)
|
||||
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
|
||||
|
||||
candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
|
||||
|
||||
# A space face may be matched by several elements within the distance
|
||||
# tolerance (e.g. a second wall layer or an element end cap). The
|
||||
# element closest to the face is the actual bounding surface, so
|
||||
# candidates are kept in order of increasing plane offset (ties broken
|
||||
# by polygon area). A candidate whose polygon is entirely covered by
|
||||
# the candidates already kept is redundant and is absorbed into the
|
||||
# larger boundary. This includes coplanar candidates: e.g. wall end
|
||||
# caps that are coplanar with the ceiling and fully covered by the
|
||||
# slab above do not get their own boundary in the reference output.
|
||||
surviving_candidates = []
|
||||
kept_union = None
|
||||
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in sorted(
|
||||
candidates, key=lambda c: (c[2] if c[2] is not None else float("inf"), -c[3].area)
|
||||
):
|
||||
if kept_union is not None and gross_boundary_polygon.difference(kept_union).area < 1e-4:
|
||||
continue
|
||||
surviving_candidates.append(
|
||||
(element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
|
||||
)
|
||||
kept_union = gross_boundary_polygon if kept_union is None else kept_union.union(gross_boundary_polygon)
|
||||
|
||||
# When a single element bounds the space face and its face is (nearly)
|
||||
# coplanar with it, the boundary covers the full space face (1st level
|
||||
# semantics) rather than the clipped intersection with the element
|
||||
# face. This matches the reference output and avoids leaving corner
|
||||
# slivers to be filled by an extra gap boundary.
|
||||
if len(surviving_candidates) == 1:
|
||||
element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal = surviving_candidates[0]
|
||||
if plane_offset_min is not None and plane_offset_min <= FULL_FACE_OFFSET_TOL:
|
||||
gross_boundary_polygon = space_face_polygon
|
||||
surviving_candidates[0] = (element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
|
||||
|
||||
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in surviving_candidates:
|
||||
exterior_boundary_polygon = gross_boundary_polygon
|
||||
@@ -466,6 +430,102 @@ def auto_generate_boundaries(
|
||||
return boundaries
|
||||
|
||||
|
||||
def _match_space_face_candidates(
|
||||
building_elements,
|
||||
shapes,
|
||||
element_ngons,
|
||||
space_matrix,
|
||||
space_matrix_inv,
|
||||
space_verts_l,
|
||||
space_face_polygon,
|
||||
face_matrix_inv,
|
||||
space_face_normal_world,
|
||||
all_filling_ids,
|
||||
matched_walls_and_columns,
|
||||
relaxed=False,
|
||||
):
|
||||
"""Match all building elements against one space face and select survivors.
|
||||
|
||||
When ``relaxed`` is True, sloped element faces that only roughly oppose
|
||||
the space face are matched using a footprint-scaled distance tolerance.
|
||||
Relaxed matches keep ``plane_offset_min`` as None so they sort last and
|
||||
never trigger the single-candidate full-face substitution.
|
||||
"""
|
||||
candidates = []
|
||||
for element in building_elements:
|
||||
if element.id() in all_filling_ids:
|
||||
continue
|
||||
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
|
||||
continue
|
||||
match = _match_element_to_space_face(
|
||||
element,
|
||||
shapes,
|
||||
element_ngons,
|
||||
space_matrix,
|
||||
space_matrix_inv,
|
||||
space_verts_l,
|
||||
space_face_polygon,
|
||||
face_matrix_inv,
|
||||
space_face_normal_world,
|
||||
relaxed=relaxed,
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
dist_min, plane_offset_min, matching_polygons, matched_elem_normal = match
|
||||
|
||||
if len(matching_polygons) == 1:
|
||||
gross_boundary_polygon = matching_polygons[0]
|
||||
else:
|
||||
gross_boundary_polygon = shapely.ops.unary_union(matching_polygons)
|
||||
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
|
||||
|
||||
candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
|
||||
|
||||
# A space face may be matched by several elements within the distance
|
||||
# tolerance (e.g. a second wall layer or an element end cap). The
|
||||
# element closest to the face is the actual bounding surface, so
|
||||
# candidates are kept in order of increasing plane offset (ties broken
|
||||
# by polygon area). A candidate whose polygon is entirely covered by
|
||||
# the candidates already kept is redundant and is absorbed into the
|
||||
# larger boundary. This includes coplanar candidates: e.g. wall end
|
||||
# caps that are coplanar with the ceiling and fully covered by the
|
||||
# slab above do not get their own boundary in the reference output.
|
||||
surviving_candidates = []
|
||||
kept_union = None
|
||||
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in sorted(
|
||||
candidates, key=lambda c: (c[2] if c[2] is not None else float("inf"), -c[3].area)
|
||||
):
|
||||
if kept_union is not None and gross_boundary_polygon.difference(kept_union).area < 1e-4:
|
||||
continue
|
||||
surviving_candidates.append((element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal))
|
||||
kept_union = gross_boundary_polygon if kept_union is None else kept_union.union(gross_boundary_polygon)
|
||||
|
||||
# When a single element bounds the space face and its face is (nearly)
|
||||
# coplanar with it, the boundary covers the full space face (1st level
|
||||
# semantics) rather than the clipped intersection with the element
|
||||
# face. This matches the reference output and avoids leaving corner
|
||||
# slivers to be filled by an extra gap boundary. It only applies to
|
||||
# strict anti-parallel matches: a relaxed (sloped) face is not coplanar
|
||||
# with the space face, so plane_offset_min is None and the substitution
|
||||
# is skipped.
|
||||
if not relaxed and len(surviving_candidates) == 1:
|
||||
element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal = surviving_candidates[0]
|
||||
if plane_offset_min is not None and plane_offset_min <= FULL_FACE_OFFSET_TOL:
|
||||
gross_boundary_polygon = space_face_polygon
|
||||
surviving_candidates[0] = (element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal)
|
||||
|
||||
return surviving_candidates
|
||||
|
||||
|
||||
def _match_element_to_space_face(
|
||||
element,
|
||||
shapes,
|
||||
@@ -476,6 +536,7 @@ def _match_element_to_space_face(
|
||||
space_face_polygon,
|
||||
face_matrix_inv,
|
||||
space_face_normal_world,
|
||||
relaxed=False,
|
||||
):
|
||||
"""Match a building element's faces against a single space face.
|
||||
|
||||
@@ -520,11 +581,22 @@ def _match_element_to_space_face(
|
||||
is_parallel = _is_x(angle, 0, tolerance=2)
|
||||
|
||||
if not (is_anti_parallel or (is_horizontal_face and is_parallel and is_valid_element)):
|
||||
continue
|
||||
if not relaxed or angle < 92:
|
||||
continue
|
||||
|
||||
sv_in_elem = sb.np_apply_matrix(space_centroid[np.newaxis], element_matrix_inv @ space_matrix)[0]
|
||||
dist = float(np.dot(sv_in_elem - elem_verts_l[0], elem_face_normal_local))
|
||||
dist_tol = 0.05 if is_horizontal_face else 0.5
|
||||
if relaxed:
|
||||
# A sloped face is not coplanar with the space face, so use the
|
||||
# closest space face vertex and a tolerance scaled to the face
|
||||
# footprint instead of the centroid-based strict tolerance.
|
||||
bounds = space_face_polygon.bounds
|
||||
diagonal = ((bounds[2] - bounds[0]) ** 2 + (bounds[3] - bounds[1]) ** 2) ** 0.5
|
||||
dist_tol = max(0.5, 0.1 * diagonal)
|
||||
space_verts_in_elem = sb.np_apply_matrix(space_verts_l, element_matrix_inv @ space_matrix)
|
||||
dist = float(np.min(np.abs(np.dot(space_verts_in_elem - elem_verts_l[0], elem_face_normal_local))))
|
||||
else:
|
||||
sv_in_elem = sb.np_apply_matrix(space_centroid[np.newaxis], element_matrix_inv @ space_matrix)[0]
|
||||
dist = float(np.dot(sv_in_elem - elem_verts_l[0], elem_face_normal_local))
|
||||
if abs(dist) > dist_tol:
|
||||
continue
|
||||
|
||||
@@ -560,10 +632,11 @@ def _match_element_to_space_face(
|
||||
matching_polygons.append(gross_boundary_polygon)
|
||||
matched_elem_normal = elem_face_normal_world
|
||||
dist_min = abs(dist) if dist_min is None else min(dist_min, abs(dist))
|
||||
space_face_normal = _face_normal(space_verts_l)
|
||||
if space_face_normal is not None:
|
||||
plane_offset = abs(float(np.dot(space_face_normal, elem_verts_in_space[0] - space_verts_l[0])))
|
||||
plane_offset_min = plane_offset if plane_offset_min is None else min(plane_offset_min, plane_offset)
|
||||
if not relaxed:
|
||||
space_face_normal = _face_normal(space_verts_l)
|
||||
if space_face_normal is not None:
|
||||
plane_offset = abs(float(np.dot(space_face_normal, elem_verts_in_space[0] - space_verts_l[0])))
|
||||
plane_offset_min = plane_offset if plane_offset_min is None else min(plane_offset_min, plane_offset)
|
||||
|
||||
if not matching_polygons:
|
||||
return None
|
||||
|
||||
@@ -31,7 +31,7 @@ import ifcopenshell.util.shape
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
|
||||
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0, direction=(0.0, 0.0, 1.0)):
|
||||
"""Add a body representation (extruded polyline) to an element."""
|
||||
if not ifc_file.by_type("IfcProject"):
|
||||
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
|
||||
@@ -59,7 +59,7 @@ def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
|
||||
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))
|
||||
direction = ifc_file.createIfcDirection((float(direction[0]), float(direction[1]), float(direction[2])))
|
||||
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
|
||||
rep = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
@@ -266,6 +266,43 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
|
||||
assert boundary.RelatedBuildingElement == wall
|
||||
assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL"
|
||||
|
||||
def test_generates_boundary_for_sloped_wall(self):
|
||||
# A wall whose inner face is tilted away from the space (extrusion
|
||||
# direction (0, 0.5, 1.0) makes the face ~153 deg from the seed face
|
||||
# normal) previously matched no seed face, so no boundary was
|
||||
# generated. The relaxed fallback match should attribute the space's
|
||||
# north face to the wall.
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, wall, [[-5, 5], [5, 5], [5, 5.2], [-5, 5.2]], 3.0, direction=(0.0, 0.5, 1.0))
|
||||
shapes = _build_shapes_dict(self.file, [space, wall])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, list)
|
||||
wall_boundaries = _boundaries_for(result, wall)
|
||||
assert len(wall_boundaries) == 1
|
||||
assert wall_boundaries[0].PhysicalOrVirtualBoundary == "PHYSICAL"
|
||||
assert _outer_boundary_area(wall_boundaries[0]) == pytest.approx(26.83, abs=1e-2)
|
||||
|
||||
def test_generates_boundary_for_sloped_roof(self):
|
||||
# A roof slab extruded at a tilt so its underside is a sloped plane
|
||||
# (~163 deg from the seed top face normal) cutting across the space's
|
||||
# top face. Previously no seed face matched it, so no boundary was
|
||||
# generated.
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
roof = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(
|
||||
self.file, roof, [[-5, -7], [5, -7], [5, -5], [-5, -5]], 2.0, z_offset=3.2, direction=(0.0, 1.0, 0.3)
|
||||
)
|
||||
shapes = _build_shapes_dict(self.file, [space, roof])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, list)
|
||||
roof_boundaries = _boundaries_for(result, roof)
|
||||
assert len(roof_boundaries) == 1
|
||||
assert roof_boundaries[0].PhysicalOrVirtualBoundary == "PHYSICAL"
|
||||
assert _outer_boundary_area(roof_boundaries[0]) == pytest.approx(19.16, abs=1e-2)
|
||||
|
||||
def test_wall_boundary_with_window_has_no_inner_boundary(self):
|
||||
space, wall, window = _add_wall_with_window(self.file)
|
||||
shapes = _build_shapes_dict(self.file, [space, wall, window])
|
||||
|
||||
Reference in New Issue
Block a user