diff --git a/3MF/agent-harness/cli_anything/threemf/core/inspector.py b/3MF/agent-harness/cli_anything/threemf/core/inspector.py index 7527d4327..d94b828f6 100644 --- a/3MF/agent-harness/cli_anything/threemf/core/inspector.py +++ b/3MF/agent-harness/cli_anything/threemf/core/inspector.py @@ -43,7 +43,7 @@ class InspectParams: # Constants # --------------------------------------------------------------------------- -_GROUP_DISTANCE_MM = 0.5 # max center-to-center distance to merge circles +_GROUP_DISTANCE_MM = 0.5 # max (centre, radius) distance to merge circles into one hole # --------------------------------------------------------------------------- @@ -109,6 +109,7 @@ def inspect_mesh( num_planes = params.num_planes holes: list[DetectedHole] = [] hole_id = 0 + hole_mesh = backend.mesh_to_trimesh(mesh_data) if groups else None for group in groups: radii = np.array([c["radius"] for c in group]) @@ -131,15 +132,43 @@ def inspect_mesh( if confidence < params.min_confidence: continue - # Axis extent - group_levels = [c["level"] for c in group] - axis_min = float(min(group_levels)) - axis_max = float(max(group_levels)) - # Centre (average across all detections) centres = np.array([c["center"] for c in group]) avg_center = (float(np.mean(centres[:, 0])), float(np.mean(centres[:, 1]))) + # Axis extent -- measured from the hole's actual wall vertices. The + # cross-section planes are inset from the mesh bounds, so their levels + # under-report a through hole whose wall vertices sit exactly on the + # part faces; that gap made resize's axial band miss the rim vertices + # and move nothing. We therefore read the extent from real wall + # vertices, but only within this group's detected span extended by one + # sampling inset (clamped to the mesh) -- so we recover the rims without + # letting an unrelated coaxial same-radius feature elsewhere along the + # axis stretch the extent. Fall back to plane levels if none match. + group_levels = [c["level"] for c in group] + level_min, level_max = float(min(group_levels)), float(max(group_levels)) + inset = (axis_hi - axis_lo) * backend.PLANE_INSET_FRACTION + search_lo = max(axis_lo, level_min - inset) + search_hi = min(axis_hi, level_max + inset) + wall_min, wall_max = _wall_axial_extent( + vertices, avg_center, mean_radius, axis, perp_axes, search_lo, search_hi, + ) + if wall_min is None: + axis_min, axis_max = level_min, level_max + else: + axis_min, axis_max = wall_min, wall_max + + # Keep only interior holes, not exterior contours. A hole's cylindrical + # wall faces the void, so its surface normals point toward the axis; the + # part's outer boundary, a bare cylinder, or a solid boss all face + # outward. Classifying by wall-normal orientation keeps through *and* + # blind holes while rejecting the exterior circles that splitting groups + # by radius would otherwise surface as resizable holes. + if not _is_interior_hole( + hole_mesh, avg_center, mean_radius, axis, perp_axes, axis_min, axis_max, + ): + continue + # Step 6 -- count wall vertices vertex_count = _count_wall_vertices( vertices, avg_center, mean_radius, axis, perp_axes, axis_min, axis_max, @@ -240,17 +269,25 @@ def _perpendicular_axes(axis: int) -> tuple[int, int]: def _group_circles(circles: list[dict]) -> list[list[dict]]: - """Group circles from different planes by centre proximity. + """Group circles from different planes into distinct holes. - Uses scipy hierarchical clustering with a distance threshold of - ``_GROUP_DISTANCE_MM``. + Circles belonging to the same hole share both a centre **and** a radius + across cross-section planes. Concentric features with different radii -- + e.g. a hole bored through a solid body, whose section also yields the + body's outer contour -- must therefore stay in separate groups; otherwise + their radii get averaged into a meaningless diameter (the inner hole and + outer wall collapse into one bogus circle). We cluster on the + ``(centre_x, centre_y, radius)`` feature vector via scipy hierarchical + clustering with a distance threshold of ``_GROUP_DISTANCE_MM``. """ if len(circles) == 1: return [circles] - centres = np.array([c["center"] for c in circles]) - link = linkage(centres, method="single", metric="euclidean") + features = np.array( + [[c["center"][0], c["center"][1], c["radius"]] for c in circles] + ) + link = linkage(features, method="single", metric="euclidean") labels = fcluster(link, t=_GROUP_DISTANCE_MM, criterion="distance") groups: dict[int, list[dict]] = {} @@ -260,6 +297,88 @@ def _group_circles(circles: list[dict]) -> list[list[dict]]: return list(groups.values()) +def _wall_axial_extent( + vertices: np.ndarray, + center: tuple[float, float], + radius: float, + axis: int, + perp_axes: tuple[int, int], + search_lo: float, + search_hi: float, + tolerance: float = 0.06, +) -> tuple[float, float] | tuple[None, None]: + """Return the axial ``(min, max)`` of the hole's wall vertices. + + A vertex counts as wall when its radial distance from the hole axis is + within *tolerance* of *radius* **and** its axial coordinate lies in + ``[search_lo, search_hi]``. The axial window keeps a coaxial same-radius + feature elsewhere on the axis from stretching the extent. Returns + ``(None, None)`` when no wall vertex matches, so the caller can fall back + to the sampled plane levels. + """ + + ax0, ax1 = perp_axes + dx = vertices[:, ax0] - center[0] + dy = vertices[:, ax1] - center[1] + dist = np.sqrt(dx * dx + dy * dy) + axial_all = vertices[:, axis] + on_wall = ( + (np.abs(dist - radius) < tolerance) + & (axial_all >= search_lo) + & (axial_all <= search_hi) + ) + if not np.any(on_wall): + return None, None + axial = axial_all[on_wall] + return float(np.min(axial)), float(np.max(axial)) + + +def _is_interior_hole( + tm, + center: tuple[float, float], + radius: float, + axis: int, + perp_axes: tuple[int, int], + axis_min: float, + axis_max: float, + tolerance: float = 0.06, +) -> bool: + """Whether a detected circle bounds an interior hole rather than an exterior contour. + + A hole's cylindrical wall faces the void, so its outward surface normals + point *toward* the axis; the part's outer boundary, a bare cylinder, or a + solid boss all face *away* from the axis. We classify by the mean radial + component of the wall-face normals -- which keeps through **and** blind holes + (a vertex-enclosure test fails on a blind hole's floor end, where no material + lies beyond the radius). + + Needs consistent winding, which valid 3MF provides. If the winding is + inconsistent or the wall can't be sampled we keep the candidate rather than + risk dropping a real hole. + """ + + if tm is None or not tm.is_winding_consistent: + return True + + ax0, ax1 = perp_axes + centroids = tm.triangles_center + du = centroids[:, ax0] - center[0] + dv = centroids[:, ax1] - center[1] + dist = np.sqrt(du * du + dv * dv) + + band = (centroids[:, axis] >= axis_min - tolerance) & ( + centroids[:, axis] <= axis_max + tolerance + ) + on_wall = band & (np.abs(dist - radius) < max(0.15 * radius, 0.2)) + if not np.any(on_wall): + return True + + safe_dist = np.where(dist > 1e-12, dist, 1.0) + normals = tm.face_normals + radial_dot = normals[:, ax0] * (du / safe_dist) + normals[:, ax1] * (dv / safe_dist) + return bool(np.mean(radial_dot[on_wall]) < 0.0) + + def _count_wall_vertices( vertices: np.ndarray, center: tuple[float, float], diff --git a/3MF/agent-harness/cli_anything/threemf/core/modifier.py b/3MF/agent-harness/cli_anything/threemf/core/modifier.py index 5baa8d16f..a8062928f 100644 --- a/3MF/agent-harness/cli_anything/threemf/core/modifier.py +++ b/3MF/agent-harness/cli_anything/threemf/core/modifier.py @@ -9,6 +9,7 @@ import numpy as np from cli_anything.threemf.core.parser import MeshData, ThreeMFData from cli_anything.threemf.core.inspector import ( DetectedHole, + InspectParams, inspect_mesh, ) @@ -29,9 +30,21 @@ def resize_holes( hole_ids: list[int], target_diameter: float, mesh_index: int = 0, + params: InspectParams | None = None, ) -> tuple[ThreeMFData, list[dict]]: """Resize specified holes to *target_diameter* and return a **new** ``ThreeMFData``. + Parameters + ---------- + params: + Hole-detection parameters. ``resize_holes`` re-detects the mesh's + holes to resolve *hole_ids* to geometry, so *params* **must match the + parameters used to inspect the mesh** (especially ``axis``) -- the + ``hole_id`` numbering is only meaningful relative to one detection + run. When ``None`` the inspector defaults are used (axis=0/X), which + only find *hole_ids* that ``inspect`` surfaces with those same + defaults. + Returns ------- tuple @@ -48,7 +61,7 @@ def resize_holes( _validate_resize_inputs(threemf_data, hole_ids, target_diameter, mesh_index) mesh = threemf_data.meshes[mesh_index] - detected_holes = inspect_mesh(mesh) + detected_holes = inspect_mesh(mesh, params) detected_ids = {h.hole_id for h in detected_holes} unknown = set(hole_ids) - detected_ids diff --git a/3MF/agent-harness/cli_anything/threemf/tests/test_core.py b/3MF/agent-harness/cli_anything/threemf/tests/test_core.py index 7ab681164..edad5fb96 100644 --- a/3MF/agent-harness/cli_anything/threemf/tests/test_core.py +++ b/3MF/agent-harness/cli_anything/threemf/tests/test_core.py @@ -178,6 +178,53 @@ def _make_fake_hole( ) +def _make_washer_mesh( + r_inner: float = 4.0, r_outer: float = 20.0, height: float = 10.0 +) -> MeshData: + """A washer (hollow cylinder) whose axis runs along Z. + + Its cross-section perpendicular to Z is an annulus -- two *concentric* + circles (inner hole ``r_inner``, outer wall ``r_outer``). This is the + minimal fixture that reproduces the concentric-circle bugs: a real hole + bored through a body always yields both the hole contour and the body + contour in the same section. + """ + import trimesh + + tm = trimesh.creation.annulus(r_min=r_inner, r_max=r_outer, height=height) + return MeshData( + object_id="1", + name="washer", + vertices=np.asarray(tm.vertices, dtype=np.float64), + triangles=np.asarray(tm.faces, dtype=np.int32), + ) + + +def _make_boss_on_plate_mesh( + boss_radius: float = 5.0, boss_height: float = 20.0, + plate_size: float = 50.0, plate_thickness: float = 4.0, +) -> MeshData: + """A solid cylindrical boss standing on a wider flat plate (boss axis = Z). + + Built by concatenation (no boolean backend needed). Cross-sections through + the boss are circular so detection finds it, but the surrounding plate + material sits only at the base end -- so it must be classified as an exterior + contour, not an interior hole. + """ + import trimesh + + plate = trimesh.creation.box(extents=(plate_size, plate_size, plate_thickness)) + boss = trimesh.creation.cylinder(radius=boss_radius, height=boss_height, sections=48) + boss.apply_translation([0, 0, plate_thickness / 2 + boss_height / 2]) + tm = trimesh.util.concatenate([plate, boss]) + return MeshData( + object_id="1", + name="boss", + vertices=np.asarray(tm.vertices, dtype=np.float64), + triangles=np.asarray(tm.faces, dtype=np.int32), + ) + + # =========================================================================== # TestParser -- MeshData and ThreeMFData # =========================================================================== @@ -827,6 +874,117 @@ class TestInspector: result = inspector.inspect_mesh(mesh, params) assert result == [] + # --- _group_circles: concentric circles must not merge -------------------- + + def test_group_circles_keeps_concentric_different_radii_separate(self) -> None: + """Concentric circles of very different radii are distinct holes. + + Regression: grouping by centre only merged a hole's inner circle with + the body's outer contour (both centred on the axis) and averaged their + radii into a meaningless diameter. + """ + circles = [ + {"center": (0.0, 0.0), "radius": 4.0, "fit_error": 0.0, "level": z} + for z in (-2.0, -1.0, 0.0, 1.0, 2.0) + ] + [ + {"center": (0.0, 0.0), "radius": 20.0, "fit_error": 0.0, "level": z} + for z in (-2.0, -1.0, 0.0, 1.0, 2.0) + ] + groups = inspector._group_circles(circles) + mean_radii = sorted(round(float(np.mean([c["radius"] for c in g])), 1) for g in groups) + assert mean_radii == [4.0, 20.0] + + def test_group_circles_merges_same_hole_across_planes(self) -> None: + """Circles from one hole (small centre/radius jitter) stay in one group.""" + circles = [ + {"center": (0.01 * i, -0.01 * i), "radius": 4.0 + 0.002 * i, + "fit_error": 0.0, "level": float(i)} + for i in range(6) + ] + groups = inspector._group_circles(circles) + assert len(groups) == 1 + + def test_inspect_concentric_reports_only_true_inner_hole(self) -> None: + """A washer yields exactly its inner Ø8 hole -- not the merged Ø24, nor the Ø40 outer wall.""" + mesh = _make_washer_mesh(r_inner=4.0, r_outer=20.0, height=10.0) + holes = inspector.inspect_mesh(mesh, InspectParams(axis=2)) + diameters = sorted(round(h.diameter, 1) for h in holes) + assert diameters == [8.0] # only the inner hole + assert not any(abs(h.diameter - 40.0) < 1.0 for h in holes) # outer wall rejected + assert not any(abs(h.diameter - 24.0) < 1.0 for h in holes) # not the merged blob + + def test_inspect_rejects_solid_cylinder_surface(self) -> None: + """A solid cylinder's outer surface is a boundary, not a hole -- it must not be reported.""" + import trimesh + + tm = trimesh.creation.cylinder(radius=3.0, height=10.0, sections=48) + mesh = MeshData( + object_id="1", name="cyl", + vertices=np.asarray(tm.vertices, dtype=np.float64), + triangles=np.asarray(tm.faces, dtype=np.int32), + ) + assert inspector.inspect_mesh(mesh, InspectParams(axis=2)) == [] + + def test_inspect_rejects_boss_on_wider_base(self) -> None: + """A solid boss on a wider plate faces outward -- not a hole.""" + mesh = _make_boss_on_plate_mesh(boss_radius=5.0, boss_height=20.0) + holes = inspector.inspect_mesh(mesh, InspectParams(axis=2)) + assert not any(abs(h.diameter - 10.0) < 1.0 for h in holes) + + def test_inspect_keeps_blind_hole(self) -> None: + """A blind cylindrical pocket faces inward and must still be detected as a hole.""" + trimesh = pytest.importorskip("trimesh") + box = trimesh.creation.box(extents=(30, 30, 20)) + drill = trimesh.creation.cylinder(radius=4.0, height=18.0, sections=48) + drill.apply_translation([0, 0, 1]) # pocket from the top face, leaving a ~2mm floor + try: + blind = box.difference(drill) + except Exception: + pytest.skip("no boolean backend available for blind-hole fixture") + if not getattr(blind, "is_watertight", False) or len(blind.faces) <= len(box.faces): + pytest.skip("boolean backend produced no pocket") + mesh = MeshData( + object_id="1", name="blind", + vertices=np.asarray(blind.vertices, dtype=np.float64), + triangles=np.asarray(blind.faces, dtype=np.int32), + ) + holes = inspector.inspect_mesh(mesh, InspectParams(axis=2, min_confidence=0.5)) + assert any(abs(h.diameter - 8.0) < 1.0 for h in holes) + + def test_inspect_through_hole_axial_extent_spans_part(self) -> None: + """Axial extent comes from the wall vertices, spanning the full part thickness. + + Regression: the extent was taken from the inset sampling planes, so a + through hole's rim vertices fell outside the band and resize moved + nothing (vertex_count was 0). + """ + mesh = _make_washer_mesh(r_inner=4.0, r_outer=20.0, height=10.0) + holes = [h for h in inspector.inspect_mesh(mesh, InspectParams(axis=2)) + if abs(h.diameter - 8.0) < 1.0] + assert holes and holes[0].vertex_count > 0 + assert holes[0].axis_max - holes[0].axis_min == pytest.approx(10.0, abs=0.2) + + def test_wall_axial_extent_ignores_feature_outside_window(self) -> None: + """A coaxial same-radius feature outside the search window must not stretch the extent. + + Regression: the extent was read from a global radius mask, so an + unrelated same-radius ring elsewhere on the axis inflated axis_min/max + and made resize move unrelated vertices. + """ + r, n = 4.0, 24 + theta = np.linspace(0, 2 * np.pi, n, endpoint=False) + ring = np.column_stack([r * np.cos(theta), r * np.sin(theta)]) + + def ring_at_z(z: float) -> np.ndarray: + return np.column_stack([ring[:, 0], ring[:, 1], np.full(n, z)]) + + # hole wall spans z in [0, 10]; an unrelated same-radius ring sits at z=50 + verts = np.vstack([ring_at_z(0.0), ring_at_z(10.0), ring_at_z(50.0)]) + lo, hi = inspector._wall_axial_extent( + verts, (0.0, 0.0), r, 2, (0, 1), search_lo=-0.5, search_hi=10.5, + ) + assert (lo, hi) == pytest.approx((0.0, 10.0)) + # =========================================================================== # TestRepair -- repair_mesh, individual repair functions, fix_normals @@ -1082,3 +1240,21 @@ class TestModifier: ) resize_single_hole(mesh, hole, target_diameter=3.0) np.testing.assert_array_equal(mesh.vertices, original) + + # --- resize_holes respects detection params (axis parity with inspect) ----- + + def test_resize_holes_default_params_miss_non_default_axis_hole(self) -> None: + """Without params, resize re-detects on axis 0 and can't see a Z-axis hole.""" + data = _make_threemf_data(_make_washer_mesh(r_inner=4.0, r_outer=20.0)) + with pytest.raises(ValueError, match="Unknown hole_ids"): + resize_holes(data, [0], 12.0, mesh_index=0) + + def test_resize_holes_with_axis_params_resizes_non_default_axis_hole(self) -> None: + """With matching params (axis=2), the hole is detected and its wall vertices move.""" + data = _make_threemf_data(_make_washer_mesh(r_inner=4.0, r_outer=20.0)) + new_data, changes = resize_holes( + data, [0], 12.0, mesh_index=0, params=InspectParams(axis=2), + ) + assert changes and changes[0]["vertices_moved"] > 0 + # the modified mesh is a genuinely new object (resize actually happened) + assert new_data.meshes[0] is not data.meshes[0] diff --git a/3MF/agent-harness/cli_anything/threemf/threemf_cli.py b/3MF/agent-harness/cli_anything/threemf/threemf_cli.py index 6ba82b22b..9b3df6a2d 100644 --- a/3MF/agent-harness/cli_anything/threemf/threemf_cli.py +++ b/3MF/agent-harness/cli_anything/threemf/threemf_cli.py @@ -205,18 +205,34 @@ def inspect(file, planes, min_diameter, min_confidence, axis, mesh): help="Hole ID(s) to resize (from inspect output)") @click.option("--diameter", "-d", type=float, required=True, help="Target diameter (mm)") @click.option("--output", "-o", "output_path", type=str, required=True, help="Output file path") +@click.option("--planes", "-n", type=int, default=20, help="Number of cross-section planes (hole detection)") +@click.option("--min-diameter", type=float, default=0.5, help="Minimum hole diameter (mm)") +@click.option("--min-confidence", type=float, default=0.7, help="Minimum detection confidence") +@click.option("--axis", "-a", type=int, default=0, help="Hole axis: 0=X, 1=Y, 2=Z (must match inspect)") @click.option("--mesh", "-m", type=int, default=0, help="Mesh object index") @click.option("--overwrite", is_flag=True, help="Overwrite output if exists") @handle_error -def resize(file, hole_ids, diameter, output_path, mesh, overwrite): - """Resize cylindrical holes to specified diameter.""" +def resize(file, hole_ids, diameter, output_path, planes, min_diameter, min_confidence, axis, mesh, overwrite): + """Resize cylindrical holes to specified diameter. + + Hole IDs come from `inspect`. Pass the SAME detection options here as you + gave `inspect` (especially --axis), otherwise the IDs won't line up. + """ if os.path.exists(output_path) and not overwrite: raise FileExistsError(f"Output file exists: {output_path}. Use --overwrite to replace.") if diameter <= 0: raise ValueError("Diameter must be positive") data = parser.parse_3mf(file) - new_data, changes = modifier.resize_holes(data, list(hole_ids), diameter, mesh_index=mesh) + params = inspector.InspectParams( + num_planes=planes, + min_hole_diameter=min_diameter, + min_confidence=min_confidence, + axis=axis, + ) + new_data, changes = modifier.resize_holes( + data, list(hole_ids), diameter, mesh_index=mesh, params=params, + ) # Auto-repair after resize target_mesh = new_data.meshes[mesh] diff --git a/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py b/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py index 3677ec9e5..969a06c02 100644 --- a/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py +++ b/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py @@ -212,6 +212,13 @@ def fit_circle_least_squares( # Cross-section analysis # --------------------------------------------------------------------------- +# Cross-section planes are inset from the mesh bounds by this fraction of the +# axis span, to avoid degenerate slices exactly at the faces. The inspector +# reuses it to recover a through hole's rim vertices, which sit up to one inset +# beyond the outermost sampled plane. +PLANE_INSET_FRACTION = 0.02 + + def _axis_plane_normal(axis: int) -> np.ndarray: """Return a unit normal vector for the given axis index (0=X, 1=Y, 2=Z).""" normal = np.zeros(3, dtype=np.float64) @@ -288,7 +295,7 @@ def cross_section_circles( return [] # Inset slightly to avoid degenerate boundary slices - margin = span * 0.02 + margin = span * PLANE_INSET_FRACTION plane_values = np.linspace(axis_min + margin, axis_max - margin, num_planes) results: list[dict[str, Any]] = []