mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-12 22:43:41 +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
|
space_face_normals_world[space_ngon_idx] = space_face_normal_world
|
||||||
covered_by_face[space_ngon_idx] = []
|
covered_by_face[space_ngon_idx] = []
|
||||||
|
|
||||||
candidates = []
|
surviving_candidates = _match_space_face_candidates(
|
||||||
for element in building_elements:
|
building_elements,
|
||||||
if element.id() in all_filling_ids:
|
shapes,
|
||||||
continue
|
element_ngons,
|
||||||
if element.is_a() in ("IfcWall", "IfcColumn") and element.id() in matched_walls_and_columns:
|
space_matrix,
|
||||||
continue
|
space_matrix_inv,
|
||||||
match = _match_element_to_space_face(
|
space_verts_l,
|
||||||
element,
|
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,
|
shapes,
|
||||||
element_ngons,
|
element_ngons,
|
||||||
space_matrix,
|
space_matrix,
|
||||||
@@ -297,59 +310,10 @@ def auto_generate_boundaries(
|
|||||||
space_face_polygon,
|
space_face_polygon,
|
||||||
face_matrix_inv,
|
face_matrix_inv,
|
||||||
space_face_normal_world,
|
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:
|
for element, dist_min, plane_offset_min, gross_boundary_polygon, matched_elem_normal in surviving_candidates:
|
||||||
exterior_boundary_polygon = gross_boundary_polygon
|
exterior_boundary_polygon = gross_boundary_polygon
|
||||||
@@ -466,6 +430,102 @@ def auto_generate_boundaries(
|
|||||||
return 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(
|
def _match_element_to_space_face(
|
||||||
element,
|
element,
|
||||||
shapes,
|
shapes,
|
||||||
@@ -476,6 +536,7 @@ def _match_element_to_space_face(
|
|||||||
space_face_polygon,
|
space_face_polygon,
|
||||||
face_matrix_inv,
|
face_matrix_inv,
|
||||||
space_face_normal_world,
|
space_face_normal_world,
|
||||||
|
relaxed=False,
|
||||||
):
|
):
|
||||||
"""Match a building element's faces against a single space face.
|
"""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)
|
is_parallel = _is_x(angle, 0, tolerance=2)
|
||||||
|
|
||||||
if not (is_anti_parallel or (is_horizontal_face and is_parallel and is_valid_element)):
|
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
|
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:
|
if abs(dist) > dist_tol:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -560,10 +632,11 @@ def _match_element_to_space_face(
|
|||||||
matching_polygons.append(gross_boundary_polygon)
|
matching_polygons.append(gross_boundary_polygon)
|
||||||
matched_elem_normal = elem_face_normal_world
|
matched_elem_normal = elem_face_normal_world
|
||||||
dist_min = abs(dist) if dist_min is None else min(dist_min, abs(dist))
|
dist_min = abs(dist) if dist_min is None else min(dist_min, abs(dist))
|
||||||
space_face_normal = _face_normal(space_verts_l)
|
if not relaxed:
|
||||||
if space_face_normal is not None:
|
space_face_normal = _face_normal(space_verts_l)
|
||||||
plane_offset = abs(float(np.dot(space_face_normal, elem_verts_in_space[0] - space_verts_l[0])))
|
if space_face_normal is not None:
|
||||||
plane_offset_min = plane_offset if plane_offset_min is None else min(plane_offset_min, plane_offset)
|
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:
|
if not matching_polygons:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import ifcopenshell.util.shape
|
|||||||
import test.bootstrap
|
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."""
|
"""Add a body representation (extruded polyline) to an element."""
|
||||||
if not ifc_file.by_type("IfcProject"):
|
if not ifc_file.by_type("IfcProject"):
|
||||||
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="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((0.0, 0.0, 1.0)),
|
||||||
ifc_file.createIfcDirection((1.0, 0.0, 0.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)
|
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
|
||||||
rep = ifc_file.create_entity(
|
rep = ifc_file.create_entity(
|
||||||
"IfcShapeRepresentation",
|
"IfcShapeRepresentation",
|
||||||
@@ -266,6 +266,43 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
|
|||||||
assert boundary.RelatedBuildingElement == wall
|
assert boundary.RelatedBuildingElement == wall
|
||||||
assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL"
|
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):
|
def test_wall_boundary_with_window_has_no_inner_boundary(self):
|
||||||
space, wall, window = _add_wall_with_window(self.file)
|
space, wall, window = _add_wall_with_window(self.file)
|
||||||
shapes = _build_shapes_dict(self.file, [space, wall, window])
|
shapes = _build_shapes_dict(self.file, [space, wall, window])
|
||||||
|
|||||||
Reference in New Issue
Block a user