diff --git a/src/ifcopenshell-python/ifcopenshell/util/boundary.py b/src/ifcopenshell-python/ifcopenshell/util/boundary.py index e067824a8d..03348a4e66 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/boundary.py +++ b/src/ifcopenshell-python/ifcopenshell/util/boundary.py @@ -64,6 +64,7 @@ FULL_FACE_OFFSET_TOL = 0.25 def _union_coplanar_face_polygon( space_verts_local, space_triangles, + space_triangle_normals, face_origin, face_normal, face_matrix_inv, @@ -76,14 +77,15 @@ def _union_coplanar_face_polygon( or an opening. Unioning the raw triangles coplanar with the face restores those holes. """ + # Vectorized coplanarity prefilter: keep only triangles whose vertices all + # lie within 1e-4 m (0.1 mm) of the face plane. + triangle_points = space_verts_local[space_triangles] + plane_offsets = np.abs(np.tensordot(triangle_points - face_origin, face_normal, axes=(2, 0))).max(axis=1) polygons = [] - for triangle in space_triangles: - points = space_verts_local[triangle] - if _face_normal(points) is None: + for triangle in np.flatnonzero(plane_offsets <= 1e-4): + if space_triangle_normals[triangle] is None: continue - if np.abs(np.dot(points - face_origin, face_normal)).max() > 1e-4: - continue - polygon = _verts_to_polygon(points, face_matrix_inv, snap=1e-6) + polygon = _verts_to_polygon(space_verts_local[space_triangles[triangle]], face_matrix_inv, snap=1e-6) if not polygon.is_valid: polygon = polygon.buffer(0) if polygon.is_empty: @@ -159,6 +161,11 @@ def auto_generate_boundaries( space_verts_local, space_shape["faces"], space_shape["edges"], merge_coplanar=True ) + # Per-triangle normals used to reconstruct space faces from their raw + # triangles (see _union_coplanar_face_polygon). Computed once instead of + # once per space face. + space_triangle_normals = [_face_normal(space_verts_local[tri]) for tri in space_shape["faces"]] + # Dissolve building element meshes — verts are in element-local coords element_ngons = {} for element in building_elements: @@ -262,6 +269,7 @@ def auto_generate_boundaries( space_face_polygon = _union_coplanar_face_polygon( space_verts_local, space_shape["faces"], + space_triangle_normals, space_verts_l[0], space_face_normal_local, face_matrix_inv, @@ -317,7 +325,9 @@ def auto_generate_boundaries( # 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. + # 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( diff --git a/src/ifcopenshell-python/test/util/test_boundary.py b/src/ifcopenshell-python/test/util/test_boundary.py index 88c9bf65c1..d70ef15583 100644 --- a/src/ifcopenshell-python/test/util/test_boundary.py +++ b/src/ifcopenshell-python/test/util/test_boundary.py @@ -208,6 +208,26 @@ def _over_splitted_ifc(): ) +def _small_house_ifczip(): + return os.path.join( + os.path.dirname(__file__), + "..", + "IfcRelSpaceBoundary_TestFiles", + "IfcRelSpaceBoundary2ndLevel", + "SmallHouse_BB_IFC4.ifczip", + ) + + +def _triangle_ifczip(): + return os.path.join( + os.path.dirname(__file__), + "..", + "IfcRelSpaceBoundary_TestFiles", + "IfcRelSpaceBoundary2ndLevel", + "Triangle_BB_IFC4.ifczip", + ) + + def _boundary_element_counts(ifc_file, space_id): space = ifc_file.by_id(space_id) counts = Counter() @@ -332,6 +352,10 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4): assert skylight[0].ParentBoundary == roof_boundaries[0] def test_over_splitted_roof_keeps_shaft_opening(self): + # Space 251 of OverSplitted_R20_IFC2X3.ifc has an L-shaped ceiling + # pierced by a shaft opening. The ceiling boundary must keep the + # opening as an inner boundary, and each shaft wall must get exactly + # one boundary instead of fragmented partial + gap boundaries. ifc_path = _over_splitted_ifc() if not os.path.exists(ifc_path): pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out") @@ -352,3 +376,28 @@ class TestAutoGenerateBoundaries(test.bootstrap.IFC4): assert shapely.Polygon(outer, inner).area == pytest.approx(9.962, abs=1e-3) for wall_id in (5832, 5877, 5922, 5967): assert len(_boundaries_for(result, copy.by_id(wall_id))) == 1 + + def test_small_house_boundaries(self): + ifc_path = _small_house_ifczip() + if not os.path.exists(ifc_path): + pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out") + ifc_file = ifcopenshell.open(ifc_path) + shapes = _build_shapes_dict_from_iterator(ifc_file) + for space_id, expected_total in [(1692, 8), (4356, 8), (4380, 13), (6185, 1)]: + copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string()) + result = subject.auto_generate_boundaries( + copy, copy.by_id(space_id), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel" + ) + assert len(result) == expected_total + + def test_triangle_boundaries(self): + ifc_path = _triangle_ifczip() + if not os.path.exists(ifc_path): + pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out") + ifc_file = ifcopenshell.open(ifc_path) + shapes = _build_shapes_dict_from_iterator(ifc_file) + copy = ifcopenshell.file.from_string(ifc_file.wrapped_data.to_string()) + result = subject.auto_generate_boundaries( + copy, copy.by_id(1573), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel" + ) + assert len(result) == 6