diff --git a/src/build123d/brep_from_stl.py b/src/build123d/brep_from_stl.py index 29dba098..66fe5d89 100644 --- a/src/build123d/brep_from_stl.py +++ b/src/build123d/brep_from_stl.py @@ -11,9 +11,9 @@ desc: The user-facing entry point is ``detect_primitives``. The reconstruction pipeline first builds a mesh index of face centers, normals, and adjacency. - It then searches for planes, spheres, and cylinders in that order so - simpler and more stable primitives claim faces before more ambiguous curved - regions are processed. + It then searches for clean proxy planes, spheres, cylinders, and fallback + normal-grouped planes in that order so stronger primitive evidence claims + faces before more ambiguous regions are processed. Each detector uses a broad classification step to identify candidate faces, sews or connects them into regions, fits a local analytic primitive, and @@ -1249,24 +1249,29 @@ def _intersect_2d_lines( ) -def detect_planes( +def detect_planes_from_clean_proxy( mesh, mesh_index: MeshIndex, +) -> list[PlanePatch]: + """Detect high-confidence planar regions from cleaned proxy faces.""" + + return _detect_planes_from_clean_proxy(mesh, mesh_index) + + +def detect_planes_from_normals( + mesh, + mesh_index: MeshIndex, + blocked_indices: set[int] | None = None, normal_digits: int = 3, plane_tolerance_factor: float = 0.003, min_component_size: int = 2, min_two_face_area_factor: float = 0.05, ) -> list[PlanePatch]: - """Detect planar regions in a mesh.""" + """Detect planar regions by grouping connected faces with matching normals.""" shape_scale = mesh.bounding_box().diagonal - plane_patches = _detect_planes_from_clean_proxy(mesh, mesh_index) - claimed = ( - set().union(*(patch.face_indices for patch in plane_patches)) - if plane_patches - else set() - ) - remaining = set(range(len(mesh_index.faces))) - claimed + plane_patches: list[PlanePatch] = [] + remaining = set(range(len(mesh_index.faces))) - (blocked_indices or set()) for component_indices in _plane_like_face_components( mesh_index, @@ -1292,6 +1297,35 @@ def detect_planes( return plane_patches +def detect_planes( + mesh, + mesh_index: MeshIndex, + normal_digits: int = 3, + plane_tolerance_factor: float = 0.003, + min_component_size: int = 2, + min_two_face_area_factor: float = 0.05, +) -> list[PlanePatch]: + """Detect planar regions in a mesh.""" + + clean_plane_patches = detect_planes_from_clean_proxy(mesh, mesh_index) + clean_plane_indices = ( + set().union(*(patch.face_indices for patch in clean_plane_patches)) + if clean_plane_patches + else set() + ) + normal_plane_patches = detect_planes_from_normals( + mesh, + mesh_index, + blocked_indices=clean_plane_indices, + normal_digits=normal_digits, + plane_tolerance_factor=plane_tolerance_factor, + min_component_size=min_component_size, + min_two_face_area_factor=min_two_face_area_factor, + ) + + return [*clean_plane_patches, *normal_plane_patches] + + # Cylinder detection def _cylinder_like_face_indices( mesh_index: MeshIndex, @@ -1849,12 +1883,12 @@ def detect_primitives( throughout the pipeline. Detection proceeds in stages: - 1. Planes are found first from cleaned proxy faces and coplanar connected - components. + 1. High-confidence planes are found first from cleaned proxy faces. 2. Spheres are found next from broad radius-signature classification, connected or sewn regions, local sphere fitting, and region growth. - 3. Cylinders are detected last from area-grouped sewn regions and local + 3. Cylinders are detected from area-grouped sewn regions and local cylinder seeds, then grown, refit, and validated. + 4. Remaining coplanar connected components are detected as fallback planes. Each accepted patch is converted into a build123d Face, unmatched mesh faces are returned as leftovers, and the generated code strings are sorted @@ -1864,14 +1898,14 @@ def detect_primitives( mesh_index = MeshIndex.from_shape(mesh) # shape_scale = mesh.bounding_box().diagonal - plane_patches = detect_planes(mesh, mesh_index) - plane_indices = ( - set().union(*(patch.face_indices for patch in plane_patches)) - if plane_patches + clean_plane_patches = detect_planes_from_clean_proxy(mesh, mesh_index) + clean_plane_indices = ( + set().union(*(patch.face_indices for patch in clean_plane_patches)) + if clean_plane_patches else set() ) - sphere_patches = detect_spheres(mesh, mesh_index, plane_indices) + sphere_patches = detect_spheres(mesh, mesh_index, clean_plane_indices) sphere_indices = ( set().union(*(patch.face_indices for patch in sphere_patches)) if sphere_patches @@ -1881,15 +1915,26 @@ def detect_primitives( cylinder_patches = detect_cylinders( mesh, mesh_index, - plane_indices | sphere_indices, + clean_plane_indices | sphere_indices, + ) + cylinder_indices = ( + set().union(*(patch.face_indices for patch in cylinder_patches)) + if cylinder_patches + else set() ) - # cylinder_indices = ( - # set().union(*(patch.face_indices for patch in cylinder_patches)) - # if cylinder_patches - # else set() - # ) - patches: list[DetectedPatch] = [*plane_patches, *cylinder_patches, *sphere_patches] + normal_plane_patches = detect_planes_from_normals( + mesh, + mesh_index, + blocked_indices=clean_plane_indices | sphere_indices | cylinder_indices, + ) + + patches: list[DetectedPatch] = [ + *clean_plane_patches, + *cylinder_patches, + *sphere_patches, + *normal_plane_patches, + ] # primitives: list[tuple[Face, Shell]] = [] primitives: list[Face] = [] diff --git a/tests/test_brep_from_stl.py b/tests/test_brep_from_stl.py index 3715bfd4..5ed7b187 100644 --- a/tests/test_brep_from_stl.py +++ b/tests/test_brep_from_stl.py @@ -1426,7 +1426,10 @@ def test_detect_primitives_empty_sort_pair_path(monkeypatch): mesh_index = make_mesh_index(faces=[sample_face], samples=[], face_key_lookup={}) monkeypatch.setattr(bfs.MeshIndex, "from_shape", lambda _mesh: mesh_index) - monkeypatch.setattr(bfs, "detect_planes", lambda *_args, **_kwargs: []) + monkeypatch.setattr(bfs, "detect_planes_from_clean_proxy", lambda *_args: []) + monkeypatch.setattr( + bfs, "detect_planes_from_normals", lambda *_args, **_kwargs: [] + ) monkeypatch.setattr(bfs, "detect_spheres", lambda *_args, **_kwargs: []) monkeypatch.setattr(bfs, "detect_cylinders", lambda *_args, **_kwargs: []) monkeypatch.setattr(bfs, "shapes_to_code", lambda primitives: []) @@ -1436,3 +1439,88 @@ def test_detect_primitives_empty_sort_pair_path(monkeypatch): assert list(primitives) == [] assert list(leftovers) == [sample_face] assert code_lines == [] + + +def test_detect_primitives_blocks_normal_planes_after_curved_patches(monkeypatch): + mesh = SimpleNamespace() + sample_faces = [Rectangle(1, 1).face() for _ in range(4)] + mesh_index = make_mesh_index(faces=sample_faces, samples=[], face_key_lookup={}) + clean_patch = bfs.PlanePatch( + kind="plane", + face_indices=frozenset({0}), + origin=Vector(0, 0, 0), + normal=Vector(0, 0, 1), + u_min=0.0, + u_max=1.0, + v_min=0.0, + v_max=1.0, + residual=0.0, + ) + sphere_patch = bfs.SpherePatch( + kind="sphere", + face_indices=frozenset({1}), + center=Vector(0, 0, 0), + radius=1.0, + residual=0.0, + ) + cylinder_patch = bfs.CylinderPatch( + kind="cylinder", + face_indices=frozenset({2}), + axis_point=Vector(0, 0, 0), + axis_direction=Vector(0, 0, 1), + radius=1.0, + normal_sign=1, + residual=0.0, + ) + normal_patch = bfs.PlanePatch( + kind="plane", + face_indices=frozenset({3}), + origin=Vector(0, 0, 0), + normal=Vector(0, 0, 1), + u_min=0.0, + u_max=1.0, + v_min=0.0, + v_max=1.0, + residual=0.0, + ) + calls = [] + + def fake_detect_spheres(_mesh, _mesh_index, blocked_indices): + calls.append(("spheres", blocked_indices)) + return [sphere_patch] + + def fake_detect_cylinders(_mesh, _mesh_index, blocked_indices): + calls.append(("cylinders", blocked_indices)) + return [cylinder_patch] + + def fake_detect_planes_from_normals(_mesh, _mesh_index, blocked_indices): + calls.append(("normal_planes", blocked_indices)) + return [normal_patch] + + monkeypatch.setattr(bfs.MeshIndex, "from_shape", lambda _mesh: mesh_index) + monkeypatch.setattr( + bfs, "detect_planes_from_clean_proxy", lambda *_args: [clean_patch] + ) + monkeypatch.setattr(bfs, "detect_spheres", fake_detect_spheres) + monkeypatch.setattr(bfs, "detect_cylinders", fake_detect_cylinders) + monkeypatch.setattr( + bfs, "detect_planes_from_normals", fake_detect_planes_from_normals + ) + monkeypatch.setattr(bfs, "build_plane_face", lambda _patch: sample_faces[0]) + monkeypatch.setattr( + bfs, "build_cylinder_face", lambda _patch, _support_faces: sample_faces[0] + ) + monkeypatch.setattr( + bfs, "build_sphere_face", lambda _patch, _support_faces: sample_faces[0] + ) + monkeypatch.setattr( + bfs, "shapes_to_code", lambda primitives: ["Rectangle(1, 1)"] * len(primitives) + ) + + bfs.detect_primitives(mesh) + + assert calls == [ + ("spheres", {0}), + ("cylinders", {0, 1}), + ("normal_planes", {0, 1, 2}), + ]