mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Optimize coplanar face reconstruction and add tests
Vectorize the coplanarity prefilter in _union_coplanar_face_polygon and compute per-triangle normals once instead of per space face. Add regression tests for the SmallHouse and Triangle boundary test models. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user