mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-30 17:34:27 +08:00
fix(3mf): make inspect→resize work for non-default axes and concentric holes (#370)
* fix(3mf): make inspect→resize work for non-default axes and concentric holes `inspect -a 2` could find a cylindrical hole, but `resize` then failed with "Unknown hole_ids: []"; and even when a hole was found its diameter was wrong and resize moved 0 vertices. Three root causes: 1. resize_holes() re-ran hole detection with hard-coded defaults and the resize CLI exposed no detection options, so hole_ids produced by a non-default `inspect` (e.g. --axis 2, --min-confidence) never lined up. Thread InspectParams through resize_holes() and mirror inspect's --planes/--min-diameter/--min-confidence/--axis options on `resize`. 2. _group_circles() clustered by centre only, so a hole's inner circle and the body's outer contour (both centred on the axis) merged and their radii averaged into a meaningless diameter. Cluster on (centre, radius). 3. A hole's axial extent came from the inset cross-section planes, so a through hole's rim vertices fell outside the wall-vertex band and resize moved nothing. Measure the extent from the actual wall vertices. Add regression tests for concentric grouping, true inner diameter, through-hole axial extent, and axis-param parity in resize_holes. All 122 tests pass. * fix(3mf): bound wall-vertex extent to the detected hole span Address review: _wall_axial_extent scanned every vertex by radius, so a coaxial same-radius feature elsewhere along the axis could stretch axis_min/axis_max past the inspected hole and make resize move unrelated vertices. Restrict the wall-vertex search to this group's detected span, extended by one sampling inset (shared backend.PLANE_INSET_FRACTION) and clamped to the mesh bounds, so through-hole rims are still recovered. Adds a regression test. * fix(3mf): reject outer contours so only interior holes are reported Address review: splitting circle groups by radius surfaced a round body's outer perimeter (annulus section) as its own high-confidence group, so inspect listed the part exterior as a resizable hole. Emit a hole only when the mesh has material radially outside the circle within its axial band -- true for an interior hole, false for the outer boundary, a boss, or a bare cylinder. The washer test now asserts the Ø40 outer wall is rejected; adds a solid-cylinder rejection test. * fix(3mf): classify holes by enclosure at both axial ends Address review: the "material outside the radius" check kept a solid boss that stands on a wider base, because the base rim vertices sit in the boss's axial band and are farther out than the boss radius. Require body material radially beyond the circle near BOTH ends of the extent instead: an interior hole is enclosed at both faces (or, for a blind hole, by its floor), while a boss is enclosed only at its base. Outer boundaries and bare cylinders remain rejected. Adds a boss-on-plate rejection test. * fix(3mf): classify holes by wall-normal orientation (keep blind holes) Address review: the both-ends enclosure check discarded blind pockets, whose closed-floor end has no vertices beyond the hole radius. Replace it with a surface-normal test: a hole's cylindrical wall faces the void so its normals point toward the axis, while an outer boundary, a bare cylinder, or a solid boss faces outward. This keeps through AND blind holes and still rejects those exterior contours, using the mesh's own face normals (no new dependency). If winding is inconsistent or the wall can't be sampled, keep the candidate rather than drop a real hole. Adds a blind-hole retention test.
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
Reference in New Issue
Block a user