← Files text-to-cadARCHIVED FILE

skills/dfm/scripts/mold_tool.py

49.3 KB · Oct 6, 2026 · 00:02 UTC

↓ Download file

#!/usr/bin/env python3
"""Fact-only injection-molding geometry measurements for mesh files.

Reports measurements as JSON. It never emits pass/fail, verdicts, or
moldability statuses; comparisons against resin, texture and tooling limits
belong to the skill workflow using `references/injection-molding.md`.

Requires: trimesh, numpy, rtree (pip install -r requirements.txt)

Usage:
    python mold_tool.py measure <mesh> --pull z [--units mm] [--wall-limit 45] [--zero-tol 0.05]
    python mold_tool.py pulls <mesh> [--units mm] [--wall-limit 45] [--zero-tol 0.05]

`measure` reports three fact families for one pull axis: draft, undercut
candidates, and projection. `pulls` repeats the draft and undercut summaries
for x, y, z and the mesh's own principal axes. Wall thickness is NOT measured
here; use `$dfam-check`'s `measure` for it, and ignore its `max_mm`, which
reads a plate's length rather than its thickness.

Draft is measured per triangle as the angle between the face and the pull
axis: 0 means the face is parallel to the pull (no draft), 90 means it is
perpendicular to it (a top or bottom face). Faces at or above `--wall-limit`
from the pull axis are not counted as walls. A flat face is pooled by normal
and read exactly; a CURVED face is pooled by the surface it lies on and read at
the 5th percentile of that surface's area, because a facet of a curved face is
a chord and chords scatter -- `spread_deg` reports that scatter and bounds how
far low the reading can be. A curved face merely TANGENT to the pull is
reported apart from a flat zero-draft wall because its area is a tessellation
artifact.
Which mold half forms a face, whether a parting line makes it an undercut, and
side actions are not determined here. The undercut family is a straight-pull
occlusion test: a face whose withdrawal ray is blocked by the part itself is
reported as a candidate.

If a fact family fails, the report carries `"partial": true`, names the family
in `partial_sections`, and the command exits 2, so neither a JSON reader nor a
shell caller can take a partial report for a complete one.
"""

from __future__ import annotations

import argparse
import json
import sys

import numpy as np
import trimesh

_AXES = {"x": (1.0, 0.0, 0.0), "y": (0.0, 1.0, 0.0), "z": (0.0, 0.0, 1.0)}
_BINS_DEG = [0.0, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0]
#: Length units a mesh file may carry, as a factor to millimetres. STL, OBJ,
#: PLY and 3MF are unitless in practice, so the caller states which one.
_UNITS_MM = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "in": 25.4, "ft": 304.8}
#: The occlusion test casts one ray per triangle and counts the WHOLE triangle,
#: so a CAD-coarse mesh rounds an undercut to its nearest triangle. Faces are
#: split to at most this fraction of the bounding-box diagonal before casting.
_PROBE_EDGE_FRACTION = 1 / 150
#: and never past this many probe faces, so a big mesh stays measurable.
_PROBE_FACE_BUDGET = 200_000
#: The silhouette raster is sized to the part, not to a fixed millimetre count,
#: so a part exported in metres does not print 0.0 and a 2 m panel does not
#: allocate gigabytes. The longer span sets the cell, so the grid never exceeds
#: this many cells squared.
_RASTER_CELLS_ACROSS = 2000
#: Cells per scanline band while filling one triangle, so the barycentric
#: temporaries stay small next to the raster instead of a multiple of it.
_RASTER_ROW_CELLS = 250_000


def _load(path: str, units: str = "mm") -> trimesh.Trimesh:
    """Read a mesh, scale it to millimetres, and drop degenerate faces.

    A zero-area triangle -- build123d exports a couple on any sphere -- makes
    trimesh's face normal NaN, and a NaN then reaches the report as a bare
    `NaN` token that strict JSON parsers reject.
    """
    mesh = trimesh.load(path, force="mesh")
    if isinstance(mesh, trimesh.Scene):
        mesh = mesh.dump(concatenate=True)
    factor = _UNITS_MM[units]
    if factor != 1.0:
        mesh.apply_scale(factor)
    degenerate = int(len(mesh.faces)) - int(mesh.nondegenerate_faces().sum())
    if degenerate:
        mesh.update_faces(mesh.nondegenerate_faces())
    mesh.metadata["degenerate_faces_dropped"] = degenerate
    mesh.metadata["source_units"] = units
    return mesh


def _pull_vector(spec: str) -> np.ndarray:
    """`z`, `+z`, `-y`, or `x,y,z` components, normalised.

    A leading sign belongs to an AXIS NAME only. `-1,0,1` is a vector whose
    first component is negative, not the negation of `1,0,1`: reading the sign
    as global turns it into `(-1,0,-1)` and measures a different axis than the
    one asked for.
    """
    text = (spec or "").strip().lower()
    if not text:
        raise ValueError("pull is empty; give an axis name (z, -y) or three comma-separated components")
    named = text[1:] if text[:1] in "+-" else text
    if named in _AXES:
        sign = -1.0 if text[:1] == "-" else 1.0
        vec = np.array(_AXES[named], dtype=float) * sign
    else:
        try:
            parts = [float(v) for v in text.split(",")]
        except ValueError as exc:
            raise ValueError(f"pull must be an axis name or three comma-separated components, got {spec!r}") from exc
        if len(parts) != 3:
            raise ValueError(f"pull must be an axis name or three comma-separated components, got {spec!r}")
        vec = np.array(parts, dtype=float)
    norm = float(np.linalg.norm(vec))
    if not np.isfinite(norm) or norm == 0.0:
        raise ValueError("pull vector has zero length")
    return vec / norm + 0.0  # + 0.0 so a negated axis prints 0.0, not -0.0


def _shells(mesh: trimesh.Trimesh) -> tuple[list, int]:
    """Connected shells split into solid bodies and sealed internal voids.

    `mesh.body_count` counts connected shells, and a cored part -- the standard
    molding fix for a thick section -- is ONE body with a second shell inside
    it. Coring out a boss must not make the part read as an assembly. A shell's
    normals point away from the material, so a void's enclose their own region
    inside out and its signed volume is negative; a solid body's is positive.
    """
    if mesh.body_count <= 1:
        return [mesh], 0
    parts = list(mesh.split(only_watertight=False))
    if not parts:
        return [mesh], 0
    solids, voids = [], 0
    for part in parts:
        try:
            signed = float(part.volume)
        except Exception:  # noqa: BLE001 - an open shell has no signed volume
            signed = 0.0
        if part.is_watertight and signed < 0.0:
            voids += 1
        else:
            solids.append(part)
    return (solids or parts), voids


def _mesh_facts(mesh: trimesh.Trimesh) -> dict:
    solids, voids = _shells(mesh)
    facts = {
        "bbox_mm": [round(float(v), 2) for v in mesh.extents],
        "triangle_count": int(len(mesh.faces)),
        "watertight": bool(mesh.is_watertight),
        "body_count": len(solids),
        "surface_area_mm2": round(float(mesh.area), 1),
        "volume_mm3": round(float(abs(mesh.volume)), 1) if mesh.is_volume else None,
        "source_units": mesh.metadata.get("source_units", "mm"),
    }
    dropped = int(mesh.metadata.get("degenerate_faces_dropped", 0))
    if dropped:
        facts["degenerate_faces_dropped"] = dropped
    if voids:
        facts["internal_void_count"] = voids
        facts["internal_void_note"] = (
            f"{voids} sealed internal shell(s), counted as part of one body, not as separate "
            "bodies: a cored-out section is one molded part. Nothing here measures a sealed "
            "void's own walls, and no tool reaches inside one."
        )
    if facts["body_count"] > 1:
        facts["note"] = (
            f"{facts['body_count']} separate bodies: this file is an assembly or a split export, "
            "not one molded part. Each family is measured per body; nothing here treats one body "
            "as an obstruction for another."
        )
    return facts


def _tangent_faces(mesh: trimesh.Trimesh, pull: np.ndarray, along: np.ndarray, zero_tol: float,
                   smooth_deg: float = 45.0, taller_than_its_bounds: float = 3.0,
                   crosses_across: float = 0.6) -> np.ndarray:
    """Per face: is it a facet on a curved face that is merely TANGENT to the pull?

    A sphere is parallel to the pull along a LINE, and a tessellation turns
    that line into a band of facets whose area is a property of the export --
    261 mm2 coarse, 67 mm2 fine, for the same sphere. A cylinder's wall, by
    contrast, is parallel to the pull over its whole area and is a real
    zero-draft wall.

    What separates them is what the surface does on either side: at a tangent
    line it passes THROUGH parallel, so the region has smoothly-joined
    neighbours leaning both ways. Across a sharp edge (a box corner, a cap)
    the surface is a different face, so only smooth joins are followed.

    That test alone is not enough, because a fillet joins a wall smoothly too:
    a flange rim with a radius top and bottom has leaning neighbours both ways
    and is still a real zero-draft wall. What separates those is SIZE: a band
    standing in for a tangent line is one step of the same sweep as the facets
    bounding it, so it reaches about as far along the pull as they do, where a
    wall reaches many times further.

    The unit that gets measured is the whole zero-draft REGION -- every
    zero-draft face reachable through smooth joins -- not one triangle of it.
    A flat wall is split into rows by nothing but the export's tolerance, and
    read a row at a time a real 2 mm rim shrinks to the size of the fillet
    facet beside it and files itself as tessellation. Pooled, the ratios
    measured over the fixtures are 0.85 for a sphere's equator against 4.5 for
    a 1 mm rim, 9 for a 2 mm rim at two tessellations and 22 for a 7 mm flange
    rim, so the cut between them needs no tuning.
    """
    tangent = np.zeros(len(mesh.faces), dtype=bool)
    adjacency = mesh.face_adjacency
    if not len(adjacency):
        return tangent
    tol = float(np.sin(np.radians(zero_tol)))
    zero = np.abs(along) <= tol
    if not zero.any():
        return tangent
    smooth = np.degrees(mesh.face_adjacency_angles) < smooth_deg
    pairs = adjacency[smooth]
    if not len(pairs):
        return tangent
    left, right = pairs[:, 0], pairs[:, 1]

    regions = trimesh.graph.connected_components(
        pairs[zero[left] & zero[right]], nodes=np.where(zero)[0], min_len=1)
    if not len(regions):
        return tangent
    owner = np.full(len(mesh.faces), -1, dtype=np.int64)
    for index, region in enumerate(regions):
        owner[region] = index

    reach = mesh.triangles @ pull
    face_height = reach.max(axis=1) - reach.min(axis=1)
    height = np.array([float(reach[region].max() - reach[region].min()) for region in regions])
    above = np.zeros(len(regions), dtype=bool)
    below = np.zeros(len(regions), dtype=bool)
    # The SHORTEST leaning neighbour, not the tallest: the bound stands in for
    # one step of the sweep at the crossing, and a mesher is free to put one
    # long skinny triangle anywhere along a region's border -- on one export of
    # a 2 mm rim a single fillet triangle spanning the whole 1 mm radius spoke
    # for the sweep and ate the wall. If ANY smooth leaning neighbour is much
    # shorter than the region, the surface is not turning at the region's scale.
    bound = np.full(len(regions), np.inf)
    for near, far in ((left, right), (right, left)):
        sel = (owner[near] >= 0) & (np.abs(along[far]) > tol)
        if not sel.any():
            continue
        index = owner[near][sel]
        np.logical_or.at(above, index, along[far][sel] > tol)
        np.logical_or.at(below, index, along[far][sel] < -tol)
        np.minimum.at(bound, index, face_height[far][sel])
    bound[~np.isfinite(bound)] = 0.0

    # Size alone is not enough either, because a mesher is free to build a wall
    # out of triangles that each span its whole height: OCCT fans a small bore,
    # so every facet reaches the full depth and the region is no taller than the
    # facets bounding it. A real tangent line is a CROSSING -- the surface passes
    # through parallel, so the leaning neighbours sit on either side of the
    # region ALONG the pull. Around a fanned bore they sit beside it, spanning
    # the same depth, and the two groups have the same centre. Measured: a
    # sphere's band separates by 0.89 to 1.67 of its own height, the tray's
    # 4.5 mm bores by 0.33.
    up_sum = np.zeros(len(regions))
    up_count = np.zeros(len(regions))
    down_sum = np.zeros(len(regions))
    down_count = np.zeros(len(regions))
    center = mesh.triangles_center @ pull
    for near, far in ((left, right), (right, left)):
        sel = (owner[near] >= 0) & (np.abs(along[far]) > tol)
        if not sel.any():
            continue
        index = owner[near][sel]
        lean, where = along[far][sel], center[far][sel]
        np.add.at(up_sum, index[lean > tol], where[lean > tol])
        np.add.at(up_count, index[lean > tol], 1.0)
        np.add.at(down_sum, index[lean < -tol], where[lean < -tol])
        np.add.at(down_count, index[lean < -tol], 1.0)
    crossing = np.zeros(len(regions))
    both = (up_count > 0) & (down_count > 0)
    crossing[both] = np.abs(up_sum[both] / up_count[both] - down_sum[both] / down_count[both])

    verdict = (above & below
               & (height <= taller_than_its_bounds * np.maximum(bound, 1e-9))
               & (crossing >= crosses_across * np.maximum(height, 1e-9)))
    tangent[owner >= 0] = verdict[owner[owner >= 0]]
    return tangent


def _draft_facts(mesh: trimesh.Trimesh, pull: np.ndarray, wall_limit: float, zero_tol: float) -> dict:
    """Per-face draft relative to one pull axis, pooled over wall faces."""
    normals = mesh.face_normals
    areas = mesh.area_faces
    centers = mesh.triangles_center

    along = normals @ pull
    # Rounded to the three decimals the report prints, so a wall built at
    # 2.000 deg that reads 1.99999 bins as 2-3deg like its printed value.
    draft = np.round(np.degrees(np.arcsin(np.clip(np.abs(along), 0.0, 1.0))), 3)
    walls = draft < wall_limit
    total_area = float(areas.sum())
    wall_area = float(areas[walls].sum())

    hist = {}
    edges = _BINS_DEG + [wall_limit]
    for lo, hi in zip(edges[:-1], edges[1:]):
        if hi <= lo:
            continue
        sel = walls & (draft >= lo) & (draft < hi)
        area = float(areas[sel].sum())
        if area > 0:
            hist[f"{lo:g}-{hi:g}deg"] = round(area, 2)

    zero = walls & (draft < zero_tol)
    tangent = _tangent_faces(mesh, pull, along, zero_tol)
    curved = _curved_faces(mesh)
    flat_zero = zero & ~tangent
    tangent_zero = zero & tangent
    zero_groups = _pooled_faces(mesh, flat_zero, pull, along, zero_tol=zero_tol, curved=curved)
    drafted = walls & ~zero
    drafted_area = float(areas[drafted].sum())
    drafted_mean = float((draft[drafted] * areas[drafted]).sum() / drafted_area) if drafted_area else None

    # Which way each wall opens, by area. A part whose walls open both ways
    # along one pull needs two mold halves to release them, and that split is
    # not visible from a per-face listing.
    opening = {}
    for label, sel in (("+pull", walls & (along > 0) & ~zero),
                       ("-pull", walls & (along < 0) & ~zero),
                       ("none", zero)):
        opening[label] = round(float(areas[sel].sum()), 2)

    result = {
        "pull_axis": [round(float(v), 4) for v in pull],
        "wall_limit_deg": wall_limit,
        "zero_tol_deg": zero_tol,
        "wall_area_mm2": round(wall_area, 2),
        "wall_area_pct_of_surface": round(100 * wall_area / total_area, 1) if total_area else 0.0,
        "non_wall_area_mm2": round(total_area - wall_area, 2),
        "wall_draft_histogram_mm2": hist,
        "wall_area_by_opening_mm2": opening,
        "zero_draft_wall_area_mm2": round(float(areas[flat_zero].sum()), 2),
        "zero_draft_face_count": int(flat_zero.sum()),
        "zero_draft_tangent_area_mm2": round(float(areas[tangent_zero].sum()), 2),
        "zero_draft_tangent_note": (
            "facets on a CURVED face that happens to run parallel to the pull. A curved face is "
            "tangent to the pull along a line, not over an area, so this figure scales with the "
            "tessellation and is not a zero-draft wall."
        ),
        "drafted_wall_area_mm2": round(drafted_area, 2),
        "drafted_wall_mean_draft_deg": round(drafted_mean, 3) if drafted_mean is not None else None,
        "largest_zero_draft_faces": zero_groups,
        "method": (
            "per-triangle angle to the pull axis; flat faces pooled by normal, curved faces "
            "pooled by surface and read at the 5th percentile of their area; "
            "faces at or above wall_limit_deg from the pull are not walls"
        ),
    }
    if walls.any():
        # Tangent bands are excluded here for the same reason they are excluded
        # from the zero-draft wall area: where a fillet sweeps THROUGH parallel
        # its crossing facet is not a wall at any draft, and read as one it made
        # this figure move with the mesh (0.308 deg coarse against 2.000 fine on
        # the same 2 deg wall) -- the defect min_wall_draft exists to avoid.
        # Flat faces pool by normal, which is exact for them: every facet of a
        # planar wall carries the surface's own normal. Curved faces pool by the
        # SURFACE, because a facet of one is a chord and a chord tilts less than
        # what it cuts -- read per normal, a 3 degree cone became 318 separate
        # "faces" and the lowest chord among them was reported as the part's
        # minimum.
        measurable = walls & ~tangent
        pooled = (_pooled_faces(mesh, measurable & ~curved, pull, along, limit=None, draft=draft,
                                zero_tol=zero_tol, curved=curved)
                  + _surface_faces(mesh, measurable & curved, pull, along, draft, zero_tol))
        # The lowest draft over POOLED faces, ignoring the sliver tail. Read
        # from one triangle the figure moves with the mesh -- an injection tray
        # read 0.97 deg on one export and 0.60 on another, both off 0.05 mm2
        # facets. Pooling by normal and dropping faces below an ABSOLUTE area
        # floor leaves the walls a mold sees.
        floor = max(1.0, 0.001 * float(mesh.area))
        # The floor is absolute on purpose. A coverage fraction -- the largest
        # faces making up 95% of wall area -- looks like the same filter and is
        # not: on a 200x150x100 tray it dropped a 1,600 mm2 dead-vertical boss
        # wall off the end of the list and reported the part drafted throughout,
        # while zero_draft_wall_area_mm2 in the same report said 3,360. A wall
        # that clears the floor is a wall whatever else the part carries.
        # On a body with no face above the floor at all (a finely tessellated
        # sphere) every pooled face stands, and near-zero is the honest answer.
        significant = [g for g in pooled if g["area_mm2"] >= floor] or pooled
        worst = min(significant, key=lambda g: g["draft_deg"])
        result["min_wall_draft"] = {
            "draft_deg": worst["draft_deg"],
            "area_mm2": worst["area_mm2"],
            "location_xyz": worst["centroid_xyz"],
            "opens_toward": worst["opens_toward"],
            "surface": worst["surface"],
            "method": (
                f"flat faces pooled by normal and read exactly; curved faces pooled by SURFACE and "
                f"read at the 5th percentile of their area; both above {round(floor, 2)} mm2 "
                "(0.1% of surface area), tangent bands excluded"),
        }
        if worst["surface"] == "curved":
            result["min_wall_draft"]["spread_deg"] = worst["spread_deg"]
            # A curved face is read from chords, and chords scatter. Over the
            # surface that scatter cancels; what it leaves is a figure whose
            # trustworthiness the spread states. A flat face has no such term,
            # and a reading of zero needs no allowance -- it is already the worst
            # case, and no chord hides draft below none.
            if worst["spread_deg"] > 1.0 and worst["draft_deg"] > zero_tol:
                result["min_wall_draft"]["reading_note"] = (
                    f"this came off a CURVED face whose draft varies by {worst['spread_deg']} deg "
                    "across it, so it is one figure for a face that does not have one: the 5th "
                    "percentile of its area, with area_mm2 the whole face's. On a face whose draft "
                    "SHOULD be uniform -- a conic wall, a drafted bore -- that spread is the "
                    "export's tessellation rather than the part, because a facet is a chord and a "
                    "chord tilts less than the surface it cuts; one 3.000 deg cone settled at 2.85 "
                    "once its facets agreed to within half a degree and read 0.56 when they "
                    "scattered over ten. Re-export finer before citing this against a limit."
                )
        i = int(np.argmin(np.where(walls, draft, np.inf)))
        result["min_facet_draft"] = {
            "draft_deg": round(float(draft[i]), 3),
            "area_mm2": round(float(areas[i]), 4),
            "location_xyz": [round(float(v), 2) for v in centers[i]],
            "note": "one triangle; use min_wall_draft for a figure that does not move with the mesh",
        }
        result["lowest_draft_wall_faces"] = [
            {"draft_deg": g["draft_deg"], "area_mm2": g["area_mm2"], "surface": g["surface"],
             **({"spread_deg": g["spread_deg"]} if "spread_deg" in g else {}),
             "location_xyz": g["centroid_xyz"], "opens_toward": g["opens_toward"]}
            for g in sorted(pooled, key=lambda g: g["draft_deg"])[:8]
        ]
    else:
        result["note"] = "no wall faces below wall_limit_deg for this pull axis"
    return result


def _opens(along: float, zero_tol: float) -> str:
    """Which way a face's outward normal leans along the pull axis.

    A wall that leans toward +pull is formed by, and released from, the mold
    half on that side only if the parting line puts it there. This is the
    lean, not the mold assignment.
    """
    if abs(np.degrees(np.arcsin(min(abs(float(along)), 1.0)))) < zero_tol:
        return "none"
    return "+pull" if along > 0 else "-pull"


def _curved_faces(mesh: trimesh.Trimesh, smooth_deg: float = 45.0,
                  coplanar_deg: float = 0.01) -> np.ndarray:
    """Per face: does it lie on a CURVED surface rather than a planar one?

    A face pooled out of a planar wall carries the surface's own normal, so its
    draft is exact. A facet on a curved face is a chord, and a chord tilts less
    than the surface it cuts: the same 3 degree conic wall reads 0.46, 0.68 and
    1.91 degrees at three tessellations, all of them low. That is a property of
    reading a mesh, not something a finer rule recovers, so the reading says
    which kind of face it came from instead of pretending the two are alike.
    """
    curved = np.zeros(len(mesh.faces), dtype=bool)
    adjacency = mesh.face_adjacency
    if not len(adjacency):
        return curved
    angles = np.degrees(mesh.face_adjacency_angles)
    # Smoothly joined AND not coplanar: two triangles of one flat wall join at
    # 0 degrees, a cylinder's neighbouring facets at the tessellation step. The
    # cut is 0.01 deg because a lofted planar wall reads 1.2e-6 deg of float
    # noise across its own diagonal, against 2.86 deg for a plain cylinder.
    turning = adjacency[(angles < smooth_deg) & (angles > coplanar_deg)]
    if len(turning):
        curved[turning.ravel()] = True
    return curved


def _surface_faces(mesh: trimesh.Trimesh, sel: np.ndarray, pull: np.ndarray, along: np.ndarray,
                   draft: np.ndarray, zero_tol: float, smooth_deg: float = 45.0,
                   quantile: float = 0.05) -> list:
    """Pool a CURVED face's facets by the surface they lie on, not by their normals.

    A facet of a curved face is a chord, and pooling by rounded normal gives
    each chord a face of its own: one 3 degree cone came back as 318 "faces" of
    10 to 60 mm2 apiece, and the lowest of them -- an outlier the mesher happened
    to leave -- was reported as the part's minimum wall draft at 1.9 degrees.
    The surface is one face. Pooled as one, the same cone reads 2.98 at every
    tessellation from 76 facets to 1778.

    What is reported for it is the draft at the `quantile` of its area, lowest
    first, not the mean and not the minimum. The mean would bury a real
    low-draft band on a surface whose draft varies -- a sphere's wall near its
    equator -- and the minimum is the chord outlier all over again. At 5% of
    area the figure matches the old one exactly wherever the old one was right
    (a sphere reads 2.467 and 4.221 as before, every zero-draft wall reads 0.0)
    and drops the outliers where it was not.

    `spread_deg` is the same quantile from the other end minus this one: on a
    surface whose facets should agree it is the tessellation's own scatter, and
    a large value says the export is too coarse to read this face from.
    """
    groups: list = []
    if not sel.any():
        return groups
    adjacency = mesh.face_adjacency
    if not len(adjacency):
        return groups
    smooth = adjacency[np.degrees(mesh.face_adjacency_angles) < smooth_deg]
    if len(smooth):
        left, right = smooth[:, 0], smooth[:, 1]
        smooth = smooth[sel[left] & sel[right]]
    areas = mesh.area_faces
    centers = mesh.triangles_center
    heights = mesh.triangles @ pull
    for surface in trimesh.graph.connected_components(smooth, nodes=np.where(sel)[0], min_len=1):
        area = float(areas[surface].sum())
        if area <= 0:
            continue
        order = surface[np.argsort(draft[surface])]
        share = np.cumsum(areas[order]) / area
        low = float(draft[order][int(np.searchsorted(share, quantile))])
        high = float(draft[order][min(int(np.searchsorted(share, 1.0 - quantile)), len(order) - 1)])
        weight = areas[surface]
        groups.append({
            "normal": [round(float(v), 3) for v in
                       (mesh.face_normals[surface] * weight[:, None]).sum(axis=0) / area],
            "area_mm2": round(area, 2),
            "centroid_xyz": [round(float(v), 2) for v in
                             (centers[surface] * weight[:, None]).sum(axis=0) / area],
            "extent_along_pull_mm": round(float(heights[surface].max() - heights[surface].min()), 2),
            "triangle_count": int(len(surface)),
            "draft_deg": round(low, 3),
            "spread_deg": round(high - low, 3),
            "surface": "curved",
            "opens_toward": _opens(float(np.average(along[surface], weights=weight)), zero_tol),
        })
    return groups


def _pooled_faces(mesh: trimesh.Trimesh, sel: np.ndarray, pull: np.ndarray, along: np.ndarray,
                  limit: int | None = 8, draft: np.ndarray | None = None,
                  zero_tol: float = 0.05, curved: np.ndarray | None = None) -> list:
    """Pool selected triangles that share a normal into one reported face.

    Triangles of one planar wall share a normal, so pooling by rounded normal
    reports the wall once with its total area. Two parallel walls facing the
    same way (a rib and a boss side, say) pool together as well; the pooled
    extent along the pull axis and the centroid are reported so the agent can
    tell one face from several.
    """
    if not sel.any():
        return []
    normals = mesh.face_normals[sel]
    areas = mesh.area_faces[sel]
    centers = mesh.triangles_center[sel]
    leans = along[sel]
    drafts = draft[sel] if draft is not None else np.zeros(len(areas))
    bends = curved[sel] if curved is not None else np.zeros(len(areas), dtype=bool)
    vertex_heights = mesh.triangles[sel] @ pull  # (n, 3): each corner along the pull
    keys = np.round(normals, 3)
    groups: dict = {}
    for k, a, c, hs, lean, dg, bend in zip(map(tuple, keys), areas, centers, vertex_heights,
                                           leans, drafts, bends):
        g = groups.setdefault(k, {"area": 0.0, "moment": np.zeros(3), "hmin": np.inf, "hmax": -np.inf,
                                  "count": 0, "lean": float(lean), "draft": 0.0, "curved": False})
        g["area"] += float(a)
        g["moment"] += c * float(a)
        g["hmin"] = min(g["hmin"], float(hs.min()))
        g["hmax"] = max(g["hmax"], float(hs.max()))
        g["draft"] += float(dg) * float(a)
        g["count"] += 1
        g["curved"] = g["curved"] or bool(bend)
    ranked = sorted(groups.items(), key=lambda kv: -kv[1]["area"])
    if limit is not None:
        ranked = ranked[:limit]
    return [
        {
            "normal": [round(float(v), 3) for v in k],
            "area_mm2": round(g["area"], 2),
            "centroid_xyz": [round(float(v), 2) for v in (g["moment"] / g["area"])],
            "extent_along_pull_mm": round(g["hmax"] - g["hmin"], 2),
            "triangle_count": int(g["count"]),
            "draft_deg": round(g["draft"] / g["area"], 3) if g["area"] else 0.0,
            "surface": "curved" if g["curved"] else "flat",
            "opens_toward": _opens(g["lean"], zero_tol),
        }
        for k, g in ranked
    ]


def _scale_hint(mesh: trimesh.Trimesh) -> dict:
    """Flag meshes whose declared units are probably not the real ones.

    A mesh file carries no units, so this cannot be verified, only doubted: a
    4 x 3 x 2 part is a plausible envelope in millimetres, in centimetres and in
    inches, and reading an inch file as millimetres shrinks it 25x without
    leaving the range a molded part can occupy. The envelope under every unit
    is therefore reported next to the flag, so the doubt is the reader's to
    settle rather than a threshold's.
    """
    extents = np.asarray(mesh.extents, dtype=float)
    diag = float(np.linalg.norm(extents))
    units = mesh.metadata.get("source_units", "mm")
    factor = _UNITS_MM[units]
    suspect = bool(np.isfinite(diag) and (diag < 10.0 or diag > 2_000.0))
    if suspect:
        note = (
            f"read as {units}, giving a {round(diag, 3)} mm bounding-box diagonal, which is outside "
            "the usual range of an injection-molded part. Pass --units with the file's real units."
        )
    else:
        note = (
            f"read as {units}; bounding box is a plausible molded-part envelope. A mesh file states "
            "no units, so this is not a check that the declared units are right -- compare "
            "bbox_mm_if_units against the part you expect."
        )
    return {
        "declared_units": units,
        "bbox_diagonal_mm": round(diag, 4),
        "units_suspect": suspect,
        "bbox_mm_if_units": {
            name: [round(float(v) * scale / factor, 2) for v in extents]
            for name, scale in _UNITS_MM.items()
        },
        "note": note,
    }


def _probe_mesh(mesh: trimesh.Trimesh) -> tuple[trimesh.Trimesh, float, str | None]:
    """The mesh split fine enough that one ray per triangle resolves a feature.

    The occlusion test counts a whole triangle as blocked or free, so on a
    CAD-coarse export a shelf over a plate is rounded to the plate's two
    triangles. Splitting to a maximum edge first makes the counted area follow
    the feature instead of the tessellation; the rays are still cast against
    the original mesh, whose surface is identical.

    Returns the probe mesh, the edge it was split to, and -- when the face
    budget stopped the split short -- what that cost. A mesh already over
    budget cannot be refined at all, and saying so beats reporting a probe edge
    hundreds of times the size of the part as though it were a resolution.
    """
    diag = float(np.linalg.norm(mesh.extents))
    if not np.isfinite(diag) or diag <= 0 or not len(mesh.faces):
        return mesh, 0.0, None
    triangles = mesh.triangles
    longest = np.linalg.norm(triangles - np.roll(triangles, 1, axis=1), axis=2).max(axis=1)

    def output_faces(edge: float) -> float:
        # subdivide_to_size quarters a face per pass, so a face needs
        # ceil(log2(longest / edge)) passes and becomes 4 to that power.
        passes = np.ceil(np.log2(np.maximum(longest / edge, 1.0)))
        return float(np.power(4.0, passes).sum())

    target = diag * _PROBE_EDGE_FRACTION
    edge = target
    # Coarsen until the split mesh is one a ray cast per face can finish on.
    for _ in range(16):
        if edge <= 0 or not np.isfinite(edge):
            break
        if output_faces(edge) <= _PROBE_FACE_BUDGET:
            break
        coarser = edge * 2.0
        if output_faces(coarser) >= float(len(mesh.faces)) > _PROBE_FACE_BUDGET:
            # Already over budget unsplit: doubling the edge cannot help, and
            # running the doubling out reports an edge hundreds of times the
            # part's own size as if it were the resolution achieved.
            have = float(longest.max())
            unsplit = (
                f"the mesh carries {len(mesh.faces)} faces, over the {_PROBE_FACE_BUDGET} probe "
                f"budget, so faces were NOT split before casting. Its longest edge is "
                f"{round(have, 3)} mm against the {round(target, 3)} mm this wanted")
            return mesh, have, (
                f"{unsplit}, so undercut area follows the tessellation here: a feature smaller "
                "than that edge rounds to its nearest triangle. Re-export nearer "
                f"{round(target, 3)} mm and rerun."
                if have > target else
                f"{unsplit} -- already finer, so nothing was lost; the budget only stopped this "
                "from splitting further.")
        edge = coarser
    if edge <= 0 or not np.isfinite(edge):
        return mesh, 0.0, None
    probe = mesh.subdivide_to_size(edge)
    note = None
    if edge > target * 1.5:
        note = (f"faces were split to {round(edge, 3)} mm, not the {round(target, 3)} mm this "
                f"wanted, to stay under the {_PROBE_FACE_BUDGET} probe-face budget: a feature "
                "smaller than the split edge still rounds to its nearest triangle.")
    return probe, edge, note


def _undercut_facts(mesh: trimesh.Trimesh, pull: np.ndarray, zero_tol: float) -> dict:
    """Straight-pull occlusion test for every face.

    A face whose outward normal leans toward +pull is formed by the mold half
    that withdraws toward +pull, so a ray from that face along +pull must
    leave the part without hitting it again. A face leaning toward -pull is
    tested along -pull. A zero-draft wall belongs to either half, so it is a
    candidate only when BOTH directions are blocked (a trapped slot). Blocked
    faces are undercut candidates for a straight two-half tool: a side action,
    a different parting line, or a different pull may still resolve them, and
    that judgement is not made here.

    A file with several bodies is an assembly, not one molded part, so each
    body is tested against ITSELF. A lid sitting over a base is not an
    undercut in the base.
    """
    bodies, _voids = _shells(mesh)
    if len(bodies) == 1:
        # One body: cast against the whole mesh, sealed voids included, so a
        # cored section is measured as the part it belongs to.
        bodies = [mesh]
    per_body = []
    total_candidate = 0.0
    total_leaning = 0.0
    total_trapped = 0.0
    total_area = 0.0
    total_faces = 0
    pooled: list = []
    probe_edge = 0.0
    probe_notes: list[str] = []
    for index, body in enumerate(bodies):
        probe, probe_edge, probe_note = _probe_mesh(body)
        if probe_note and probe_note not in probe_notes:
            probe_notes.append(probe_note)
        normals = probe.face_normals
        areas = probe.area_faces
        centers = probe.triangles_center
        along = normals @ pull
        lean_deg = np.degrees(np.arcsin(np.clip(np.abs(along), 0.0, 1.0)))
        zero = lean_deg < zero_tol
        eps = 1e-3 * max(float(np.linalg.norm(body.extents)), 1.0)

        def blocked(direction_sign: np.ndarray, sel: np.ndarray) -> np.ndarray:
            out = np.zeros(len(centers), dtype=bool)
            if not sel.any():
                return out
            idx = np.where(sel)[0]
            dirs = np.outer(direction_sign[idx], pull)
            origins = centers[idx] + normals[idx] * eps + dirs * eps
            out[idx] = body.ray.intersects_any(ray_origins=origins, ray_directions=dirs)
            return out

        leaning = ~zero
        sign = np.where(along >= 0, 1.0, -1.0)
        blocked_lean = blocked(sign, leaning)
        blocked_plus = blocked(np.ones(len(centers)), zero)
        blocked_minus = blocked(-np.ones(len(centers)), zero)
        trapped = zero & blocked_plus & blocked_minus
        candidates = blocked_lean | trapped
        body_area = float(areas.sum())
        total_area += body_area
        total_candidate += float(areas[candidates].sum())
        total_leaning += float(areas[blocked_lean].sum())
        total_trapped += float(areas[trapped].sum())
        total_faces += int(candidates.sum())
        pooled += _pooled_faces(probe, candidates, pull, along, zero_tol=zero_tol)
        per_body.append({
            "body": index,
            "candidate_area_mm2": round(float(areas[candidates].sum()), 2),
            "surface_area_mm2": round(body_area, 2),
        })
    result = {
        "pull_axis": [round(float(v), 4) for v in pull],
        "method": (
            "straight two-half pull; faces split to at most "
            f"{round(probe_edge, 3)} mm before casting; ray from each face along its withdrawal "
            "direction; zero-draft walls need both directions blocked; each connected body is "
            "tested against itself"
        ),
        "candidate_area_mm2": round(total_candidate, 2),
        "candidate_area_pct_of_surface": round(100 * total_candidate / total_area, 2) if total_area else 0.0,
        "candidate_face_count": total_faces,
        "leaning_blocked_area_mm2": round(total_leaning, 2),
        "trapped_zero_draft_area_mm2": round(total_trapped, 2),
        "largest_candidate_faces": sorted(pooled, key=lambda g: -g["area_mm2"])[:8],
    }
    if probe_notes:
        result["probe_resolution_note"] = " ".join(probe_notes)
    if len(bodies) > 1:
        result["body_count"] = len(bodies)
        result["per_body"] = per_body
        result["note"] = (
            "several bodies: measured per body. An assembly STL is not one molded part, and no "
            "body shadows another here."
        )
    return result


def _projection_facts(mesh: trimesh.Trimesh, pull: np.ndarray) -> dict:
    """Silhouette area along the pull, rasterised on a grid.

    Front-facing area over-counts wherever features overlap in projection,
    so the silhouette is rasterised instead. The raster is sized to the part
    rather than fixed in millimetres -- a fixed 0.25 mm grid prints 0.0 for a
    part exported in metres and allocates gigabytes for a 2 m panel -- and
    capped, so the reported resolution is what to read the figure against.
    """
    p = pull / np.linalg.norm(pull)
    helper = np.array([1.0, 0.0, 0.0]) if abs(p[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
    u = np.cross(p, helper); u /= np.linalg.norm(u)
    v = np.cross(p, u)
    pts = mesh.vertices @ np.stack([u, v], axis=1)
    span = pts.max(axis=0) - pts.min(axis=0)
    resolution_mm = float(max(span.max(), 1e-9)) / _RASTER_CELLS_ACROSS
    lo = pts.min(axis=0) - resolution_mm
    hi = pts.max(axis=0) + resolution_mm
    # Sized from the longer span, so the grid is at most _RASTER_CELLS_ACROSS
    # squared cells whatever the part's aspect ratio -- there is no second cap
    # to reach.
    size = np.maximum(np.ceil((hi - lo) / resolution_mm).astype(int), 1)
    grid = np.zeros((size[1], size[0]), dtype=bool)
    tri = (pts[mesh.faces] - lo) / resolution_mm
    for a, b, c in tri:
        x0 = max(0, int(np.floor(min(a[0], b[0], c[0])))); x1 = min(size[0] - 1, int(np.ceil(max(a[0], b[0], c[0]))))
        y0 = max(0, int(np.floor(min(a[1], b[1], c[1])))); y1 = min(size[1] - 1, int(np.ceil(max(a[1], b[1], c[1]))))
        den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1])
        if abs(den) < 1e-12 or x1 < x0 or y1 < y0:
            continue
        width = x1 - x0 + 1
        xx = (np.arange(x0, x1 + 1, dtype=np.float32) + 0.5)[None, :]
        # A triangle covering half a big panel has a full-raster bounding box,
        # and three float64 barycentric planes over it cost 24 bytes a cell
        # against the grid's 1. Rows are done in bands, one plane at a time, so
        # the peak stays a small multiple of the raster itself.
        band = max(1, int(_RASTER_ROW_CELLS // width))
        for r0 in range(y0, y1 + 1, band):
            r1 = min(y1, r0 + band - 1)
            yy = (np.arange(r0, r1 + 1, dtype=np.float32) + 0.5)[:, None]
            w0 = ((b[1] - c[1]) * (xx - c[0]) + (c[0] - b[0]) * (yy - c[1])) / den
            w1 = ((c[1] - a[1]) * (xx - c[0]) + (a[0] - c[0]) * (yy - c[1])) / den
            inside = (w0 >= -1e-6) & (w1 >= -1e-6) & ((w0 + w1) <= 1 + 1e-6)
            grid[r0:r1 + 1, x0:x1 + 1] |= inside
    heights = mesh.vertices @ p
    return {
        "pull_axis": [round(float(v), 4) for v in p],
        "projected_area_mm2": round(float(grid.sum()) * resolution_mm ** 2, 1),
        "raster_resolution_mm": round(resolution_mm, 6),
        "depth_along_pull_mm": round(float(heights.max() - heights.min()), 3),
        "volume_mm3": round(float(abs(mesh.volume)), 1) if mesh.is_volume else None,
    }


def _pull_candidates(mesh: trimesh.Trimesh) -> list[tuple[str, np.ndarray]]:
    """The three axes, plus the mesh's own principal axes.

    A part modelled off-axis has no good pull among x, y and z, and reading a
    2 degree draft as 12 under `--pull z` looks like a measurement, not a
    mismatch. Its principal axes are where its own draft was built.
    """
    candidates = [(name, np.array(axis, dtype=float)) for name, axis in _AXES.items()]
    try:
        vectors = np.asarray(mesh.principal_inertia_vectors, dtype=float)
    except Exception:  # noqa: BLE001 - a degenerate mesh has no inertia frame
        return candidates
    for index, vector in enumerate(vectors):
        norm = float(np.linalg.norm(vector))
        if not np.isfinite(norm) or norm == 0.0:
            continue
        unit = vector / norm
        # Skip one that is already an axis to within a degree.
        if any(abs(abs(float(unit @ axis)) - 1.0) < 1.5e-4 for _name, axis in candidates[:3]):
            continue
        candidates.append((f"principal{index}", unit))
    return candidates


def _pull_facts(mesh: trimesh.Trimesh, wall_limit: float, zero_tol: float) -> dict:
    """Draft pooled for each candidate pull.

    Draft magnitude does not change when the pull flips sign (only the lean
    does), so +x and -x are one candidate.
    """
    candidates = []
    for name, axis in _pull_candidates(mesh):
        facts = _safe(_draft_facts, mesh, axis, wall_limit, zero_tol)
        undercuts = _safe(_undercut_facts, mesh, axis, zero_tol)
        if "error" in facts or "error" in undercuts:
            # Keeping only the numbers discards the one field that says a family
            # did not measure, and `pulls` is the verb an agent runs FIRST to
            # choose a pull: a null candidate area that reads as "no undercuts"
            # is exactly the silent "no findings" the partial machinery exists
            # to stop. The error travels with the candidate it belongs to.
            candidates.append({
                "pull": name,
                "axis": [round(float(v), 4) for v in axis],
                "error": "; ".join(part["error"] for part in (facts, undercuts) if "error" in part),
            })
            continue
        candidates.append({
            "pull": name,
            "axis": [round(float(v), 4) for v in axis],
            "undercut_candidate_area_mm2": undercuts.get("candidate_area_mm2"),
            "wall_area_mm2": facts["wall_area_mm2"],
            "zero_draft_wall_area_mm2": facts["zero_draft_wall_area_mm2"],
            "wall_area_below_1deg_mm2": round(sum(
                v for k, v in facts["wall_draft_histogram_mm2"].items()
                if float(k.split("-")[1].rstrip("deg")) <= 1.0), 2),
            "min_wall_draft_deg": facts.get("min_wall_draft", {}).get("draft_deg"),
        })
    return {
        "wall_limit_deg": wall_limit,
        "zero_tol_deg": zero_tol,
        "note": (
            "draft magnitude is the same for +axis and -axis pulls; the lean direction differs. "
            "principal* are the mesh's own principal axes: when a part was modelled off-axis, its "
            "draft was built about one of those, not about x, y or z."
        ),
        "candidates": candidates,
    }


def _safe(fn, *args) -> dict:
    """Run one fact family, degrading to an error field instead of a traceback."""
    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 _has_error(value) -> bool:
    """Is there an `error` field anywhere under this value?

    Scanning only the top level missed a family that failed INSIDE another --
    `pulls` runs the undercut test once per candidate axis, and a report whose
    every candidate had failed still exited 0 and read as complete.
    """
    if isinstance(value, dict):
        return "error" in value or any(_has_error(v) for v in value.values())
    if isinstance(value, list):
        return any(_has_error(v) for v in value)
    return False


def _mark_partial(report: dict) -> bool:
    """Flag a report whose families did not all measure.

    A family that failed leaves an `error` field where its facts should be.
    Without this, the report reads as complete and the command exits 0, so a
    dead family becomes a silent "no findings".
    """
    failed = sorted(key for key, value in report.items() if _has_error(value))
    report["partial"] = bool(failed)
    if failed:
        report["partial_sections"] = failed
    return bool(failed)


def _normalise_argv(argv: list[str]) -> list[str]:
    """Let `--pull -z` through argparse, which reads `-z` as an option."""
    out: list[str] = []
    index = 0
    while index < len(argv):
        token = argv[index]
        if token == "--pull" and index + 1 < len(argv) and argv[index + 1].startswith("-"):
            out.append(f"--pull={argv[index + 1]}")
            index += 2
            continue
        out.append(token)
        index += 1
    return out


class _Parser(argparse.ArgumentParser):
    """A usage error exits 1, not argparse's 2.

    Exit 2 means "the report you are holding is partial" here, and a caller
    that branches on it would read a misspelled flag as a measured part with a
    failed family.
    """

    def error(self, message: str):  # noqa: D102 - argparse's own contract
        print(json.dumps({"error": f"usage: {message}", "usage": self.format_usage().strip()}))
        raise SystemExit(1)


def main(argv: list[str] | None = None) -> int:
    ap = _Parser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="command", required=True, parser_class=_Parser)

    m = sub.add_parser("measure", help="draft, undercut and projection facts for one pull axis")
    m.add_argument("mesh")
    m.add_argument("--pull", default="z", help="axis name (z, -y, or --pull=-z) or x,y,z components")
    m.add_argument("--units", default="mm", choices=sorted(_UNITS_MM), help="the mesh file's length units")
    m.add_argument("--wall-limit", type=float, default=45.0)
    m.add_argument("--zero-tol", type=float, default=0.05)

    p = sub.add_parser("pulls", help="draft and undercut summaries for x, y, z and the principal axes")
    p.add_argument("mesh")
    p.add_argument("--units", default="mm", choices=sorted(_UNITS_MM), help="the mesh file's length units")
    p.add_argument("--wall-limit", type=float, default=45.0)
    p.add_argument("--zero-tol", type=float, default=0.05)

    args = ap.parse_args(_normalise_argv(list(sys.argv[1:] if argv is None else argv)))

    try:
        mesh = _load(args.mesh, args.units)
    except Exception as e:
        print(json.dumps({"error": f"failed to load mesh: {e}"}))
        return 1

    if args.command == "measure":
        try:
            pull = _pull_vector(args.pull)
        except ValueError as e:
            print(json.dumps({"error": str(e)}))
            return 1
        report = {
            "file": args.mesh,
            "mesh": _safe(_mesh_facts, mesh),
            "scale": _safe(_scale_hint, mesh),
            "draft": _safe(_draft_facts, mesh, pull, args.wall_limit, args.zero_tol),
            "undercuts": _safe(_undercut_facts, mesh, pull, args.zero_tol),
            "projection": _safe(_projection_facts, mesh, pull),
        }
    else:
        report = {
            "file": args.mesh,
            "mesh": _safe(_mesh_facts, mesh),
            "scale": _safe(_scale_hint, mesh),
            "pulls": _safe(_pull_facts, mesh, args.wall_limit, args.zero_tol),
        }

    partial = _mark_partial(report)
    # allow_nan=False: a NaN is not JSON, and a report a parser rejects is worse
    # than one that says it failed. A NaN that survives everything upstream is a
    # failure to report, not a traceback to print: the header promises JSON on
    # stdout for every outcome.
    try:
        text = json.dumps(report, indent=2, allow_nan=False)
    except ValueError as exc:
        print(json.dumps({
            "file": args.mesh,
            "error": f"a measurement was not a finite number, so the report is not JSON: {exc}",
            "partial": True,
        }))
        return 1
    print(text)
    return 2 if partial else 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: d332a5fea83a2e7b246ecd6759050943bbae9e7a58f31bd4d5b04f45ed35e4cd