From 8e6d5b7e4f4c8b514aefaac301b02f026ff14cfd Mon Sep 17 00:00:00 2001 From: CyrilWaechter Date: Mon, 3 Aug 2026 12:51:54 +0200 Subject: [PATCH] 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. --- .../ifcopenshell/util/boundary.py | 207 ++++++++++++------ .../test/util/test_boundary.py | 41 +++- 2 files changed, 179 insertions(+), 69 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/boundary.py b/src/ifcopenshell-python/ifcopenshell/util/boundary.py index d2a2f3a1ac..91b644be83 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/boundary.py +++ b/src/ifcopenshell-python/ifcopenshell/util/boundary.py @@ -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 diff --git a/src/ifcopenshell-python/test/util/test_boundary.py b/src/ifcopenshell-python/test/util/test_boundary.py index d70ef15583..2eedf72746 100644 --- a/src/ifcopenshell-python/test/util/test_boundary.py +++ b/src/ifcopenshell-python/test/util/test_boundary.py @@ -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])