diff --git a/README.md b/README.md index 92919ee7..585be95c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ robot description files, simulation, and local review. | SRDF | Adds MoveIt planning groups, end effectors, poses, and collision rules to a URDF. | [skills/srdf](skills/srdf/SKILL.md) | | SDF | Creates simulator models and worlds with frames, physics, sensors, and lights. | [skills/sdf](skills/sdf/SKILL.md) | | SendCutSend | Checks DXF and STEP files before upload to SendCutSend. | [skills/sendcutsend](skills/sendcutsend/SKILL.md) | +| DfAM Check | Measures mesh printability per process: wall thickness, overhangs, support volume, and build orientation. | [skills/dfam-check](skills/dfam-check/SKILL.md) | | G-code | Slices supported mesh files into validated, printer-profiled FDM `.gcode` with real slicer CLIs. | [skills/gcode](skills/gcode/SKILL.md) | | Bambu Labs | Dry-runs, uploads, and cautiously starts local Bambu Lab print jobs from validated `.gcode`. | [skills/bambu-labs](skills/bambu-labs/SKILL.md) | | Implicit CAD | Creates browser-native implicit CAD models using GLSL signed-distance fields and CAD Viewer raymarch rendering. Experimental. | [skills/implicit-cad](skills/implicit-cad/SKILL.md) | diff --git a/docs/src/app/page.tsx b/docs/src/app/page.tsx index 665adc73..b8a8f10d 100644 --- a/docs/src/app/page.tsx +++ b/docs/src/app/page.tsx @@ -73,6 +73,12 @@ const skillGroups = [ path: "skills/sendcutsend", summary: "Checks DXF and STEP files before upload to SendCutSend.", }, + { + name: "DfAM Check", + path: "skills/dfam-check", + summary: + "Measures mesh printability per process: wall thickness, overhangs, support volume, and build orientation.", + }, { name: "G-code", path: "skills/gcode", diff --git a/requirements-dev.txt b/requirements-dev.txt index b6d92c8f..a4961d29 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,6 +19,7 @@ ezdxf playwright # - skills/dfam-check/requirements.txt rtree +scipy # Extra third-party requirements used by repo tests. networkx diff --git a/skills/dfam-check/SKILL.md b/skills/dfam-check/SKILL.md index bdc2c247..3be82ddb 100644 --- a/skills/dfam-check/SKILL.md +++ b/skills/dfam-check/SKILL.md @@ -68,13 +68,19 @@ Compare only trustworthy pairs of evidence. field) and the measured fact (JSON field path) for every finding. - Treat `p05_mm` below the wall-thickness limit as a violation even when `min_mm` alone could be a sampling outlier; report both values. +- On an assembly, `wall_thickness` reports `body_count` and a `per_body` + breakdown. Attribute a violation to the body it belongs to; a thin figure + pooled across bodies is not a finding against the part as a whole. - Do not apply support-angle findings to powder processes (SLS, MJF); the relevant powder-process check is trapped-volume powder escape, which this tool does not yet measure — report that as `❓ need more info` when enclosed cavities are likely. -- Do not silently rescale geometry. If the bounding box suggests wrong units - (for example a 0.03 mm "part"), report a unit/scale finding and ask the - user to confirm units before applying any material-specific comparisons. +- Do not silently rescale geometry. `scale.units_suspect` is measured from + the bounding-box diagonal: when it is `true`, the source is probably in + meters or inches, every down-facing face reads as resting on the plate, and + overhang and support figures of 0.0 mean nothing. Report a unit/scale + finding and ask the user to confirm units before comparing anything against + a material limit. - Support-volume ratios are coarse upper bounds; report them as cost signals, not hard failures, unless the user has set an explicit budget. diff --git a/skills/dfam-check/references/process-limits.md b/skills/dfam-check/references/process-limits.md index fb58e061..bdac17ed 100644 --- a/skills/dfam-check/references/process-limits.md +++ b/skills/dfam-check/references/process-limits.md @@ -38,3 +38,21 @@ category structure (feature limits §6.5, support structures §6.7). - **Orientation candidates** are the six axis-aligned rotations only. A candidate reaching materially lower support area than the current orientation is a finding worth reporting with its build-height tradeoff. + + +## What the tool measures today + +`dfam_tool.py` returns measured values for **min supported wall**, **min +unsupported wall** (both from the thickness field) and **self-supporting +angle** (from the overhang map). Those rows can be compared directly against +the limits above. + +**Min hole diameter**, **min positive feature** and **max unsupported bridge** +have no measured counterpart yet. Treat them the way the trapped-powder gap is +treated: report them as not checked, rather than inferring them from a render, +the bounding box, or the triangle count. A limit with no measurement behind it +is not a finding. + +Wall thickness is measured per connected body. An assembly reports a +`per_body` breakdown alongside the pooled figures, because a ray crossing a +mating clearance would otherwise record the fit gap as a wall. diff --git a/skills/dfam-check/requirements.txt b/skills/dfam-check/requirements.txt index efe4572f..a877a70d 100644 --- a/skills/dfam-check/requirements.txt +++ b/skills/dfam-check/requirements.txt @@ -1,3 +1,11 @@ trimesh numpy rtree +# trimesh treats these as optional extras, but this tool needs them on every +# run: scipy backs the connected-components call behind mesh.body_count and +# mesh.split, and networkx + lxml are required to load the .3mf that SKILL.md +# advertises. Without them the first measure of any mesh dies with a raw +# ModuleNotFoundError. +scipy +networkx +lxml diff --git a/skills/dfam-check/scripts/dfam_tool.py b/skills/dfam-check/scripts/dfam_tool.py index 4e4331d3..1fc99093 100644 --- a/skills/dfam-check/scripts/dfam_tool.py +++ b/skills/dfam-check/scripts/dfam_tool.py @@ -96,7 +96,81 @@ def _overhang_facts(mesh: trimesh.Trimesh, angle_limit: float) -> dict: def _wall_facts(mesh: trimesh.Trimesh, samples: int, seed: int = 42) -> dict: - """Ray-cast thickness field from area-weighted surface samples.""" + """Ray-cast thickness field, measured one connected body at a time. + + Cast against a whole assembly, a ray leaving one body can cross a mating + clearance and land on its neighbour, which records the fit gap as a wall. + A tight-clearance assembly then reports a wall-thickness violation that no + single part actually has. Splitting first makes that impossible, because + each body is only ever measured against itself. + """ + bodies = mesh.split(only_watertight=False) + if len(bodies) <= 1: + facts = _wall_facts_single(mesh, samples, seed) + facts.pop("_thickness", None) + facts.pop("_origins", None) + return facts + + areas = np.array([float(b.area) for b in bodies]) + if not np.isfinite(areas).all() or areas.sum() <= 0.0: + return { + "samples": 0, + "note": "no positive face area; mesh is degenerate, thickness not measured", + } + + # Split the sample budget by surface area so a large body is not measured + # at the same resolution as a small one, with a floor so small bodies are + # still sampled at all. + share = areas / areas.sum() + pooled: list = [] + pooled_origins: list = [] + per_body: list = [] + for i, (body, frac) in enumerate(zip(bodies, share)): + budget = max(int(round(samples * frac)), 64) + facts = _wall_facts_single(body, budget, seed + i) + per_body.append({ + "body": i, + "min_mm": facts.get("min_mm"), + "median_mm": facts.get("median_mm"), + "samples_valid": facts.get("samples_valid", 0), + }) + if "_thickness" in facts: + pooled.append(facts["_thickness"]) + pooled_origins.append(facts["_origins"]) + + if not pooled: + return { + "error": "no valid thickness samples", + "body_count": len(bodies), + "per_body": per_body, + } + + thickness = np.concatenate(pooled) + origins = np.concatenate(pooled_origins) + thin_idx = np.argsort(thickness)[:8] + + return { + "body_count": len(bodies), + "measured_per_body": True, + "samples_valid": int(len(thickness)), + "min_mm": round(float(thickness.min()), 3), + "p05_mm": round(float(np.percentile(thickness, 5)), 3), + "p25_mm": round(float(np.percentile(thickness, 25)), 3), + "median_mm": round(float(np.median(thickness)), 3), + "max_mm": round(float(thickness.max()), 3), + "per_body": per_body, + "thinnest_samples": [ + { + "location_xyz": [round(float(v), 2) for v in origins[i]], + "thickness_mm": round(float(thickness[i]), 3), + } + for i in thin_idx + ], + } + + +def _wall_facts_single(mesh: trimesh.Trimesh, samples: int, seed: int = 42) -> dict: + """Ray-cast thickness field for ONE connected body.""" rng = np.random.default_rng(seed) n = min(samples, max(len(mesh.faces), 1)) face_idx = rng.choice(len(mesh.faces), size=n, @@ -137,6 +211,11 @@ def _wall_facts(mesh: trimesh.Trimesh, samples: int, seed: int = 42) -> dict: } for i in thin_idx ], + # Underscore keys are internal: _wall_facts pools them across bodies + # and strips them before anything is printed. They are numpy arrays + # and would not survive json.dumps. + "_thickness": thickness, + "_origins": hit_origins, } @@ -192,6 +271,43 @@ def _orientation_facts(mesh: trimesh.Trimesh, angle_limit: float) -> dict: return {"angle_limit_used_deg": angle_limit, "candidates": out} +def _safe(fn, *args) -> dict: + """Run one fact family, degrading to an error field instead of a traceback. + + measure assembles every family before printing, so one throwing family + used to cost the user the facts that did compute: a planar mesh dies in + convex_hull and took the whole report with it. Each family now fails on + its own and the rest still reach the caller as JSON. + """ + try: + return fn(*args) + except Exception as exc: # noqa: BLE001 - report it, never propagate + detail = f"{type(exc).__name__}: {exc}".splitlines()[0] + return {"error": detail[:300]} + + +def _scale_hint(mesh: trimesh.Trimesh) -> dict: + """Flag meshes whose units are probably not millimetres. + + A meters-scale export measures a bbox like 0.05 x 0.02 x 0.04, which sits + under the 0.1 mm on-plate tolerance: every down-facing face reads as + resting on the plate, so overhangs and support both come back 0.0 and the + part looks like a flawless print. Give the workflow something measured to + branch on rather than asking the agent to eyeball the bounding box. + """ + diag = float(np.linalg.norm(mesh.extents)) + suspect = bool(np.isfinite(diag) and diag < 1.0) + return { + "bbox_diagonal_mm": round(diag, 4), + "units_suspect": suspect, + "note": ( + "bbox diagonal under 1 mm; source is probably in meters or inches. " + "Rescale to millimetres before trusting overhang, support or " + "thickness numbers." + ) if suspect else "bbox consistent with millimetre units", + } + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__) sub = ap.add_subparsers(dest="command", required=True) @@ -216,15 +332,17 @@ def main() -> int: if args.command == "measure": report = { "file": args.mesh, - "mesh": _mesh_facts(mesh), - "overhangs": _overhang_facts(mesh, args.angle_limit), - "wall_thickness": _wall_facts(mesh, args.samples), - "support_volume": _support_volume_facts(mesh, args.angle_limit), + "mesh": _safe(_mesh_facts, mesh), + "scale": _safe(_scale_hint, mesh), + "overhangs": _safe(_overhang_facts, mesh, args.angle_limit), + "wall_thickness": _safe(_wall_facts, mesh, args.samples), + "support_volume": _safe(_support_volume_facts, mesh, args.angle_limit), } else: report = { "file": args.mesh, - "orientations": _orientation_facts(mesh, args.angle_limit), + "scale": _safe(_scale_hint, mesh), + "orientations": _safe(_orientation_facts, mesh, args.angle_limit), } print(json.dumps(report, indent=2)) diff --git a/tests/python/skills/dfam-check/test_dfam_tool.py b/tests/python/skills/dfam-check/test_dfam_tool.py index bbf3e022..3549cadc 100644 --- a/tests/python/skills/dfam-check/test_dfam_tool.py +++ b/tests/python/skills/dfam-check/test_dfam_tool.py @@ -177,3 +177,100 @@ class CliTest(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class MultiBodyWallTest(unittest.TestCase): + """A mating clearance is not a wall. + + Cast against a whole assembly, a ray can leave one body, cross the fit gap + and land on its neighbour, recording the gap as wall thickness. Measuring + per body makes that impossible. + """ + + def _assembly(self, tmp: Path) -> str: + socket = Box(10, 10, 10) - Box(6.6, 6.6, 20) # 1.7 mm wall + peg = Box(6.0, 6.0, 8) # 0.3 mm clearance a side + export_stl(socket, str(tmp / "socket.stl")) + export_stl(peg, str(tmp / "peg.stl")) + merged = trimesh.util.concatenate([ + trimesh.load(str(tmp / "socket.stl"), force="mesh"), + trimesh.load(str(tmp / "peg.stl"), force="mesh"), + ]) + path = tmp / "mating.stl" + merged.export(str(path)) + return str(path) + + def test_each_body_is_measured_against_itself(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + facts = dfam_tool._wall_facts(dfam_tool._load(self._assembly(tmp)), samples=2000) + + self.assertEqual(facts["body_count"], 2) + self.assertTrue(facts["measured_per_body"]) + + thinnest = min(b["min_mm"] for b in facts["per_body"] if b["min_mm"] is not None) + # 0.3 mm is the fit gap; the thinnest real wall is the 1.7 mm socket. + self.assertGreater(thinnest, 1.0) + + def test_internal_arrays_never_reach_the_payload(self) -> None: + """The pooled arrays are numpy and would not survive json.dumps.""" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + for path in (self._assembly(tmp), _stl(Box(20, 20, 15), tmp, "solid")): + facts = dfam_tool._wall_facts(dfam_tool._load(path), samples=400) + leaked = [k for k in facts if k.startswith("_")] + self.assertEqual(leaked, [], f"{path} leaked {leaked}") + json.dumps(facts) + + def test_single_body_keeps_the_flat_result_shape(self) -> None: + """One body must not acquire per-body keys, and must stay accurate. + + Uses a hollow box because a solid box has a bimodal thickness field + (15 mm through the flats, 20 mm through the sides), so asserting a + single median would only be testing the triangulation. + """ + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + path = _stl(_hollow_box(2.0), tmp, "hollow_single") + facts = dfam_tool._wall_facts(dfam_tool._load(path), samples=800) + + self.assertNotIn("body_count", facts) + self.assertNotIn("per_body", facts) + self.assertAlmostEqual(facts["median_mm"], 2.0, delta=0.4) + + +class ResilienceTest(unittest.TestCase): + def test_one_failing_family_does_not_lose_the_others(self) -> None: + """A planar mesh kills convex_hull; the rest of the report survives.""" + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "planar.stl" + trimesh.Trimesh( + vertices=[[0, 0, 0], [10, 0, 0], [0, 10, 0]], + faces=[[0, 1, 2]], + process=False, + ).export(str(path)) + payload = _run_cli(["measure", str(path)]) + + self.assertIn("error", payload["support_volume"]) + self.assertNotIn("error", payload["overhangs"]) + self.assertNotIn("error", payload["mesh"]) + + +class ScaleHintTest(unittest.TestCase): + """Sub-millimetre bbox means wrong units, not a flawless part.""" + + def test_meters_scale_mesh_is_flagged(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + small = dfam_tool._load(_stl(Box(0.05, 0.02, 0.04), tmp, "meters")) + big = dfam_tool._load(_stl(Box(20, 20, 15), tmp, "mm")) + + self.assertTrue(dfam_tool._scale_hint(small)["units_suspect"]) + self.assertFalse(dfam_tool._scale_hint(big)["units_suspect"]) + + def test_scale_appears_in_both_subcommands(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + path = _stl(Box(20, 20, 15), tmp, "box") + self.assertIn("scale", _run_cli(["measure", path])) + self.assertIn("scale", _run_cli(["orientations", path]))