fix(dfam-check): address maintainer review on #265

Blockers
--------
1. requirements.txt was missing scipy, networkx and lxml. With only
   trimesh/numpy/rtree installed the first measure of any mesh died with a raw
   ModuleNotFoundError from mesh.body_count, and .3mf could not load at all.
   Reproduced in a clean venv, then confirmed fixed there: all four advertised
   formats now load and measure. Only scipy is mirrored into
   requirements-dev.txt; networkx and lxml are already listed under the repo
   tests block, matching how trimesh is handled.

2. Wall thickness is now measured per connected body. Cast against a whole
   assembly, a ray can leave one body, cross a mating clearance and land on
   its neighbour, recording the fit gap as a wall - so a tight-clearance
   assembly reported a wall violation no single part had. _wall_facts splits
   first and measures each body against itself, pooling the results and adding
   a per_body breakdown. Sample budget is shared by surface area with a floor
   so small bodies are still sampled.

Should-fix
----------
3. Each fact family now runs through _safe and degrades to an error field.
   A planar mesh dies in convex_hull; previously that cost the user the whole
   report, including the facts that had computed.

4. New scale block reports bbox_diagonal_mm and units_suspect. A meters-scale
   mesh sits under the 0.1 mm on-plate tolerance, so every down-facing face
   reads as on-plate and the part looks like a flawless print. SKILL.md now
   branches on the measured field instead of asking the agent to eyeball the
   bounding box.

5. Added the skill to the README table and the docs page array.

6. process-limits.md now names which rows have a measured counterpart today
   (walls, self-supporting angle) and which do not (hole diameter, positive
   feature, unsupported bridge), closing the eyeballing loophole.

SKILL.md also gained a rule to attribute an assembly's wall violation to the
body it belongs to.

Verification
------------
- Clean venv built from the skill's own requirements: 26 checks covering
  multi-body clearance, 81k-face performance, a 42-body assembly, planar,
  meters-scale, broken export, all four formats, degenerate mesh.
- Per-body change costs ~150 ms on an 81k-face mesh (7493 -> 7643 ms); the
  bulk of that time is trimesh's pure-Python ray fallback, not the split.
- Real part measurements unchanged: L-bracket still 1200 mm2 unsupported,
  9.0 mm median wall.
- gcode/urdf/dxf suites fail identically with and without this change; those
  are pre-existing.
This commit is contained in:
knewnothing-git
2026-08-17 20:06:01 +05:30
parent a63a432593
commit cb6f984bbc
8 changed files with 264 additions and 9 deletions
+1
View File
@@ -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) |
+6
View File
@@ -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",
+1
View File
@@ -19,6 +19,7 @@ ezdxf
playwright
# - skills/dfam-check/requirements.txt
rtree
scipy
# Extra third-party requirements used by repo tests.
networkx
+9 -3
View File
@@ -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.
@@ -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.
+8
View File
@@ -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
+124 -6
View File
@@ -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))
@@ -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]))