Reorder STL primitive detection to defer normal planes to enable more cylinders to be found
Some checks are pending
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-14, 3.10) (push) Waiting to run
tests / tests (macos-14, 3.14) (push) Waiting to run
tests / tests (macos-15-intel, 3.10) (push) Waiting to run
tests / tests (macos-15-intel, 3.14) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.14) (push) Waiting to run
tests / tests (windows-latest, 3.10) (push) Waiting to run
tests / tests (windows-latest, 3.14) (push) Waiting to run
Run type checking / typecheck (3.10) (push) Waiting to run
Run type checking / typecheck (3.14) (push) Waiting to run

This commit is contained in:
Roger Maitland 2026-04-16 10:35:17 -04:00
parent a72776d466
commit 771a00d9eb
2 changed files with 161 additions and 28 deletions

View file

@ -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] = []

View file

@ -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}),
]