master
John Lauer Publish 1.2.1 8b75307 18d ago
#!/usr/bin/env python3
"""LEGIBILITY GATE for a laser-marked part: `adom-aiflow-etch check <etched.step> [--outline <svg>]
[--footprint <kicad_mod>] [--plan <x.mark-plan.json>]`. Runs automatically at the end of every etch.

Checks (FAIL blocks; WARN is printed as a Hint):
  axis      text baseline vs the planned long axis: parallel PASS; across the long axis FAIL (unless the
            band is near square, ratio < 1.15)
  upright   reading direction in the standard pose (pin 1 / pad 1 lower left): matches the plan PASS,
            reversed FAIL ("flip 180")
  size      smallest line's cap height vs what fits: >= 80 % PASS, 60-80 % WARN, < 60 % FAIL
  band      the mark lies inside the usable band (off the terminations, clear of the pin-1 dot): FAIL if not
  flat      the mark is flat and coplanar with its face (within 0.02 mm): FAIL if raised or buried
  group     Adom.LaserEtch next to Adom.Chip: WARN on the legacy 'laser_etch' name
  colour    a light mark (sRGB luminance >= 0.5): WARN when dark (only right on a light body)
  widget    cap height in pixels with the iso outline drawn 320 px wide: >= 6 px PASS, 4-6 WARN, < 4 FAIL
  outline   (with --outline) the outline's name runs the same direction as the model mark, sits on the
            same face and within 15 % of the same place, and its caps are >= 6 px at 320 px
Writes <etched>.check.json and a top view of the mark from the service, <etched>.check.png, with the
band (green), the reading arrow and the verdict drawn on it. Exit status 1 on FAIL."""
import argparse, io, json, math, os, re, sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import step_text as ST
import mark_svc as SVC
import mark_plan as MP
import mark_geom as MG

SIZE_PASS, SIZE_FAIL = 0.80, 0.60
PX_PASS, PX_FAIL = 6.0, 4.0


def _polylines(svg):
    out = []
    for m in re.finditer(r"<polyline\b([^>]*)>", svg):
        at = m.group(1)
        pts = re.search(r'points="([^"]+)"', at)
        w = re.search(r'stroke-width="([^"]+)"', at); c = re.search(r'stroke="([^"]+)"', at)
        if pts:
            out.append(dict(pts=[tuple(map(float, p.split(","))) for p in pts.group(1).split()], w=float(w.group(1)) if w else 1.0, c=c.group(1) if c else "",
                            dash=("stroke-dasharray" in at), filt=("filter=" in at), attrs=re.sub(r'\s*points="[^"]*"', "", at)))
    return out


def outline_text(svg, plate=False):
    """Split an outline SVG into (name strokes, part strokes). adom-aiflow-etch outline wraps the name in
    <g id="adom-mark-name">; a service-drawn name is the trailing run of one stroke class that follows a
    different class, is no wider and has fewer strokes (multi-layer styles: hidden dashed / glow / core)."""
    m = re.search(r'<g id="adom-mark-name"[^>]*>(.*?)</g>', svg, re.S)
    if m:
        return (_polylines(m.group(1)) or None), _polylines(svg[:m.start()] + svg[m.end():])
    pl = _polylines(svg)
    if not pl:
        return None, pl
    cls = lambda t: (t["w"], t["c"], t.get("dash"), t.get("filt"))
    runs = []
    for t in pl:
        if runs and cls(runs[-1][0]) == cls(t):
            runs[-1].append(t)
        else:
            runs.append([t])
    if len(runs) >= 2:
        last, prev = runs[-1], runs[-2]
        if plate or (last[0]["w"] <= prev[0]["w"] and len(last) < len(prev) and not last[0].get("dash")):
            return last, [t for r in runs[:-1] for t in r]
    return None, pl


_iso = MG.iso


def check_outline(svg_path, plan, mark_c_posed, meta_posed, mark_box_posed=None, verts=None):
    svg = open(svg_path).read()
    vb = re.search(r'viewBox="([^"]+)"', svg)
    W = float(vb.group(1).split()[2]) if vb else 320.0
    txt, body = outline_text(svg)
    res = dict(file=svg_path)
    if not txt:
        # drawn from the etched STEP: the mark's own glyph edges are in the outline, so it IS the mark;
        # confirm strokes sit where the mark projects
        pl = _polylines(svg)
        allp = [p for t in pl for p in t["pts"]]
        if mark_box_posed and allp:
            corners = []
            for f in meta_posed["faces"]:
                b = f["bbox_mm"]
                for x in (b[0], b[3]):
                    for y in (b[1], b[4]):
                        for z in (b[2], b[5]):
                            corners.append(_iso((x, y, z)))
            bx0 = min(p[0] for p in allp); bx1 = max(p[0] for p in allp); by0 = min(p[1] for p in allp); by1 = max(p[1] for p in allp)
            mx0 = min(p[0] for p in corners); mx1 = max(p[0] for p in corners); my0 = min(p[1] for p in corners); my1 = max(p[1] for p in corners)
            sc = ((bx1 - bx0) / (mx1 - mx0) + (by1 - by0) / (my1 - my0)) / 2
            mb = mark_box_posed
            pc = [_iso((x, y, z)) for x in (mb[0], mb[3]) for y in (mb[1], mb[4]) for z in (mb[2], mb[5])]
            qx0 = bx0 + (min(p[0] for p in pc) - mx0) * sc; qx1 = bx0 + (max(p[0] for p in pc) - mx0) * sc
            qy0 = by0 + (min(p[1] for p in pc) - my0) * sc; qy1 = by0 + (max(p[1] for p in pc) - my0) * sc
            inside = [t for t in pl if all(qx0 - 2 <= p[0] <= qx1 + 2 and qy0 - 2 <= p[1] <= qy1 + 2 for p in t["pts"])]
            if len(inside) >= 6:
                res.update(status="PASS", hint=f"outline drawn from the etched STEP: {len(inside)} mark strokes on the marked face, the name is the mark itself (same face, direction and place by construction)")
                return res
        res.update(status="WARN", hint="no name in this outline (no separate name strokes and no mark edges where the mark is); nothing to compare")
        return res
    pts = [p for t in txt for p in t["pts"]]
    bpts = [p for t in body for p in t["pts"]]
    # direction: principal axis of the name strokes; sign: first stroke -> last stroke
    mx = sum(p[0] for p in pts) / len(pts); my = sum(p[1] for p in pts) / len(pts)
    sxx = sum((p[0] - mx) ** 2 for p in pts); syy = sum((p[1] - my) ** 2 for p in pts); sxy = sum((p[0] - mx) * (p[1] - my) for p in pts)
    ang = 0.5 * math.atan2(2 * sxy, sxx - syy)
    d = (math.cos(ang), math.sin(ang))
    f0 = txt[0]["pts"]; f1 = txt[-1]["pts"]
    c0 = (sum(p[0] for p in f0) / len(f0), sum(p[1] for p in f0) / len(f0)); c1 = (sum(p[0] for p in f1) / len(f1), sum(p[1] for p in f1) / len(f1))
    lines2 = len(plan["text"]["lines"]) == 2
    if lines2:     # the first stroke starts line 1 at the left; the last ends line 2 at the right: still left -> right overall
        pass
    if (c1[0] - c0[0]) * d[0] + (c1[1] - c0[1]) * d[1] < 0:
        d = (-d[0], -d[1])
    # expected on-screen reading direction: the planned baseline in the outline's pose
    qp = plan["outline"]["poseRotZDeg"] // 90
    u = MP._rot(plan["readingDirection"]["vector"], "z", qp)
    a0 = _iso((0, 0, 0)); a1 = _iso(u)
    e = (a1[0] - a0[0], a1[1] - a0[1]); n = math.hypot(*e); e = (e[0] / n, e[1] / n)
    cosang = d[0] * e[0] + d[1] * e[1]
    angle = math.degrees(math.acos(max(-1, min(1, abs(cosang)))))
    # position: calibrate the drawing against the model's vertices (exact for an orthographic view);
    # without them, fit the projected face boxes to the drawn extents
    if verts:
        cal = MG.calibrate(body, verts)
        ex, ey = cal["o"][0] + cal["s"] * _iso(mark_c_posed)[0], cal["o"][1] + cal["s"] * _iso(mark_c_posed)[1]
        bx0 = min(p[0] for p in bpts); bx1 = max(p[0] for p in bpts); by0 = min(p[1] for p in bpts); by1 = max(p[1] for p in bpts)
    corners = []
    for f in meta_posed["faces"]:
        b = f["bbox_mm"]
        for x in (b[0], b[3]):
            for y in (b[1], b[4]):
                for z in (b[2], b[5]):
                    corners.append(_iso((x, y, z)))
    bx0 = min(p[0] for p in bpts); bx1 = max(p[0] for p in bpts); by0 = min(p[1] for p in bpts); by1 = max(p[1] for p in bpts)
    mx0 = min(p[0] for p in corners); mx1 = max(p[0] for p in corners); my0 = min(p[1] for p in corners); my1 = max(p[1] for p in corners)
    s = ((bx1 - bx0) / (mx1 - mx0) + (by1 - by0) / (my1 - my0)) / 2
    if not verts:
        exp = _iso(mark_c_posed)
        ex = bx0 + (exp[0] - mx0) * s; ey = by0 + (exp[1] - my0) * s
    tx = (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2; ty = (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2
    dist = math.hypot(tx - ex, ty - ey) / max(bx1 - bx0, by1 - by0)
    size_ratio = None
    if mark_box_posed and verts:
        mb = mark_box_posed
        pc = [_iso((x, y, z)) for x in (mb[0], mb[3]) for y in (mb[1], mb[4]) for z in (mb[2], mb[5])]
        along_m = max(p[0] * d[0] + p[1] * d[1] for p in pc) - min(p[0] * d[0] + p[1] * d[1] for p in pc)
        along_t = max(p[0] * d[0] + p[1] * d[1] for p in pts) - min(p[0] * d[0] + p[1] * d[1] for p in pts)
        size_ratio = along_t / max(along_m * cal["s"], 1e-9)
    # cap height on screen: extent of the strokes across the reading direction, per line
    perp = (-d[1], d[0])
    across = [p[0] * perp[0] + p[1] * perp[1] for p in pts]
    span = max(across) - min(across)
    cap_px = (span / (2 + MP.GAP) if lines2 else span) * 320.0 / W
    ok_dir = cosang > 0.85
    reversed_ = cosang < -0.85
    st = "PASS"
    hints = []
    if not ok_dir:
        st = "FAIL"
        hints.append("outline name runs " + ("the opposite way (upside down)" if reversed_ else (f"across the long axis ({angle:.0f} deg off on screen)" if angle > 45 else f"{angle:.0f} deg off")) +
                     f" from the model's mark; regenerate it with the planner's recipe: {plan['outline']['recipe']}")
    if dist > 0.15:
        st = "FAIL"
        hints.append(f"outline name sits {100 * dist:.0f}% of the part size away from where the model's mark is; draw it on the same face (the planner's band)")
    if cap_px < PX_FAIL:
        st = "FAIL"; hints.append(f"outline name caps are {cap_px:.1f} px at 320 px: unreadable in the widget")
    elif cap_px < PX_PASS and st == "PASS":
        st = "WARN"; hints.append(f"outline name caps are {cap_px:.1f} px at 320 px: small in the widget")
    if size_ratio is not None and not (0.7 <= size_ratio <= 1.4):
        if st == "PASS":
            st = "WARN"
        hints.append(f"outline name is {size_ratio:.2f}x the etched mark's length; draw it with the planner's band (adom-aiflow-etch outline)")
    res.update(status=st, angleDeg=round(angle, 1), sameDirection=bool(ok_dir), offsetPct=round(100 * dist, 1), capHeightPx320=round(cap_px, 1),
               lengthVsMark=round(size_ratio, 2) if size_ratio else None, hints=hints)
    return res


def render_top(step_text, a, plan, band, status, path, frame_label):
    try:
        png, hdr = SVC.thumbnail(step_text, "top", 640, 640, job="mark-check-top")
    except Exception as ex:  # the view is evidence, the verdict does not depend on it
        return None, f"top view not rendered: {ex}"
    try:
        from PIL import Image, ImageDraw
    except ImportError:
        open(path, "wb").write(png); return path, None
    im = Image.open(io.BytesIO(png)).convert("RGB")
    bb = json.loads(hdr.get("X-Bbox-Mm") or hdr.get("x-bbox-mm"))
    dx, dy = bb[0][1] - bb[0][0], bb[1][1] - bb[1][0]
    sc = 0.95 * 640 / max(dx, dy); cx, cy = (bb[0][0] + bb[0][1]) / 2, (bb[1][0] + bb[1][1]) / 2
    P = lambda x, y: (320 + (x - cx) * sc, 320 - (y - cy) * sc)
    d = ImageDraw.Draw(im)
    x0, y0, x1, y1 = band
    d.rectangle([P(x0, y1), P(x1, y0)], outline=(60, 200, 90), width=2)
    if a:
        c = a["centre"]; u = a["u"]; L = (a["bbox"][3] - a["bbox"][0]) if a["axis"] == "x" else (a["bbox"][4] - a["bbox"][1])
        s0 = P(c[0] - u[0] * L * 0.55, c[1] - u[1] * L * 0.55); s1 = P(c[0] + u[0] * L * 0.55, c[1] + u[1] * L * 0.55)
        d.line([s0, s1], fill=(255, 200, 40), width=3)
        hx, hy = s1[0] - s0[0], s1[1] - s0[1]; n = math.hypot(hx, hy) or 1; hx, hy = hx / n * 14, hy / n * 14
        d.polygon([s1, (s1[0] - hx - hy * 0.5, s1[1] - hy + hx * 0.5), (s1[0] - hx + hy * 0.5, s1[1] - hy - hx * 0.5)], fill=(255, 200, 40))
    col = {"PASS": (60, 200, 90), "WARN": (240, 180, 40), "FAIL": (235, 70, 60)}[status]
    d.rectangle([0, 0, 640, 26], fill=(13, 17, 23)); d.text((8, 7), f"{status}  {plan['mpn']}  top view ({frame_label})  green: usable band  arrow: reading direction", fill=col)
    im.save(path)
    return path, None


def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_path=None, render=True, quiet=False):
    if plan is None and plan_path:
        j = json.load(open(plan_path)); plan = j.get("plan", j)
    if plan is None:
        side = os.path.splitext(step_path)[0] + ".mark-plan.json"
        if os.path.exists(side):
            j = json.load(open(side)); plan = j.get("plan", j)
    s = ST.Step(step_path)
    name = MG.mark_name(s)
    if plan is None:
        if not mpn and name:             # unknown text: plan for as many characters as the mark has glyphs
            s0 = ST.Step(step_path); s0.flatten(); a0 = MG.analyse(s0, name)
            mpn = "X" * (len(a0["faces"]) if a0 else 10)
        plan = MP.plan(step_path, footprint, mpn or os.path.splitext(os.path.basename(step_path))[0].upper())
    if plan["longAxis"].get("override") and os.path.exists(plan.get("step") or ""):
        # an --along override is judged against the planner's own default, never against itself
        plan = MP.plan(plan["step"], plan.get("footprint"), plan.get("mpn"), plan["face"])
    s.flatten()
    turns = [(t["axis"], t["quarterTurns"]) for t in plan["standUp"]]
    if plan["band"]["sideTurn"]:
        turns.append((plan["band"]["sideTurn"]["axis"], plan["band"]["sideTurn"]["quarterTurns"]))
    for ax, q in turns:
        s.rotate90(ax, q)
    checks, hints = [], []
    def add(key, status, msg, **kw):
        checks.append(dict(check=key, status=status, detail=msg, **kw))
    band = plan["band"]["regionMm"]; bz = plan["band"]["zMm"]
    L, Hh = plan["band"]["lengthMm"], plan["band"]["heightMm"]
    u_plan = plan["readingDirection"]["vector"]
    if plan["band"]["sideTurn"]:
        u_plan = plan["band"]["sideTurn"]["baselineInTurnedFrame"]
    a = MG.analyse(s, name) if name else None
    if not a:
        add("mark", "FAIL", "no laser mark in this STEP (no Adom.LaserEtch / laser_etch group)")
    else:
        # axis
        ua = a["u"]
        dot = ua[0] * u_plan[0] + ua[1] * u_plan[1]
        long_mm, short_mm = max(L, Hh), min(L, Hh)
        aspect = long_mm / max(short_mm, 1e-9)
        if abs(dot) < 0.5:
            sev = "FAIL" if aspect >= 1.15 or plan["longAxis"].get("why", "").startswith("two-terminal") else "WARN"
            add("axis", sev, f"Text runs across the {(Hh if True else L):.2f} mm side; rerun with the planner default (along {L:.2f} mm).", angleDeg=90)
        else:
            add("axis", "PASS", f"text along the {L:.2f} mm axis", angleDeg=0)
            if dot < 0:
                add("upright", "FAIL", "Upside down in the standard pose; flip 180 (rerun adom-aiflow-etch without --along so the planner reads it from pin 1).")
            else:
                add("upright", "PASS", f"upright, left to right, pin 1 {plan['pin1']['corner'].replace('/', ' ')}")
        # size against what fits, with the mark's own line count
        nlines = len(a["lines"])
        txt = "".join(plan["text"]["lines"]) if plan["text"]["lines"] else (mpn or "")
        if abs(dot) >= 0.5:
            fit_one = MP.text_fit(len(txt), L, Hh)
            best = max(plan["text"]["smallestLineMm"], fit_one)
            if nlines == 2 and len(plan["text"]["lines"]) == 2:
                best = plan["text"]["smallestLineMm"]
        else:
            best = plan["text"]["smallestLineMm"]
        cap = a["capMm"]
        ratio = cap / max(best, 1e-9)
        pct_band = 100 * cap * (nlines + (nlines - 1) * MP.GAP) / max(Hh, 1e-9)
        msg = f"text height {cap:.3f} mm, {100 * ratio:.0f}% of what fits ({best:.3f} mm), block fills {pct_band:.0f}% of the {Hh:.2f} mm band height"
        if ratio >= SIZE_PASS:
            add("size", "PASS", msg, capMm=round(cap, 4), fitMm=round(best, 4))
        else:
            add("size", "FAIL" if ratio < SIZE_FAIL else "WARN", f"Text height {cap:.2f} mm is {100 * ratio:.0f}% of what fits; increase to ~{best:.2f} mm (rerun adom-aiflow-etch with the planner's defaults).",
                capMm=round(cap, 4), fitMm=round(best, 4))
        # band and plane
        tol = 0.01
        mb = a["bbox"]
        # control points of glyph curves can sit a hair outside the drawn outline: allow 3 % of the cap
        tol += 0.03 * cap
        inside = mb[0] >= band[0] - tol and mb[1] >= band[1] - tol and mb[3] <= band[2] + tol and mb[4] <= band[3] + tol
        add("band", "PASS" if inside else "FAIL", "inside the usable band" + (" (between the terminations)" if plan["band"].get("trimmedEnds") or "two-terminal" in plan["longAxis"]["why"] else "") if inside else
            f"the mark leaves the usable band {['%.2f' % v for v in band]} mm (onto the terminations / pin-1 dot / edge); rerun with the planner defaults")
        dz = a["z"] - bz
        flat_ok = a["flatMm"] < 2e-3 and -0.002 <= dz <= 0.02
        add("flat", "PASS" if flat_ok else "FAIL", f"flat, coplanar with the face ({1000 * dz:+.0f} um)" if flat_ok else
            f"mark is {'not flat (%.3f mm thick)' % a['flatMm'] if a['flatMm'] >= 2e-3 else ('%.3f mm above' % dz if dz > 0 else '%.3f mm below' % -dz)} the face; marks are flat light faces on the surface, never raised or recessed lettering")
        add("group", "PASS" if name == "Adom.LaserEtch" else "WARN", "Adom.LaserEtch group" if name == "Adom.LaserEtch" else "legacy 'laser_etch' group; re-etch with adom-aiflow-etch for Adom.Chip / Adom.LaserEtch")
        rgb = MG.colour(s, name)
        if rgb:
            lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
            add("colour", "PASS" if lum >= 0.5 else "WARN", f"light mark (sRGB {', '.join('%.2f' % c for c in rgb)})" if lum >= 0.5 else
                "dark mark: the rule is a flat white (light) mark; keep a dark one only on a light body (aluminium can, white LED housing) and say so", rgb=rgb)
        # widget readability (iso outline at 320 px)
        u_iso = plan["readingDirection"]["vector"]
        cap_px = MP.iso_cap_px(cap, u_iso, plan["widget"]["pxPerMm"], plan["face"])
        st = "PASS" if cap_px >= PX_PASS else "WARN" if cap_px >= PX_FAIL else "FAIL"
        add("widget", st, f"{cap_px:.1f} px caps with the outline 320 px wide" + ("" if st == "PASS" else ": too small to read in the widget; use two lines or a shorter mark (the user must agree)"), capHeightPx320=round(cap_px, 1))
    if outline:
        sp = ST.Step(step_path); sp.flatten()
        for t in plan["standUp"]:
            sp.rotate90(t["axis"], t["quarterTurns"])
        qp = plan["outline"]["poseRotZDeg"] // 90
        sp.rotate90("z", qp)
        meta_p = SVC.step_meta(sp.text, "mark-check-outline-meta")
        b = plan["band"]["regionMm"]
        c = [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2, plan["band"]["zMm"]]
        ap_ = MG.analyse(sp) if name else None
        if plan["band"]["sideTurn"]:
            c = ap_["centre"] if ap_ else c
        else:
            c = MP._rot(c, "z", qp)
        o = check_outline(outline, plan, c, meta_p, ap_["bbox"] if ap_ else None, verts=MG.vertices(sp))
        add("outline", o["status"], "; ".join(o.get("hints", [])) or (o.get("hint") or f"outline name: same direction, {o.get('offsetPct')}% offset, {o.get('capHeightPx320')} px caps"), **{k: v for k, v in o.items() if k not in ("status", "hints", "hint")})
    worst = "FAIL" if any(c["status"] == "FAIL" for c in checks) else "WARN" if any(c["status"] == "WARN" for c in checks) else "PASS"
    base = os.path.splitext(step_path)[0]
    rep = dict(schema="adom/mark-check@1", step=os.path.abspath(step_path), mpn=plan.get("mpn"), status=worst, checks=checks,
               plan=dict(summary=plan["summary"], band=plan["band"]["regionMm"], readingDirection=plan["readingDirection"]["vector"], lines=plan["text"]["lines"]),
               thresholds=dict(sizePass=SIZE_PASS, sizeFail=SIZE_FAIL, widgetPxPass=PX_PASS, widgetPxFail=PX_FAIL, outlineOffsetMax=0.15, coplanarMaxMm=0.02))
    if render:
        # show it in the standard pose: the planned reading direction to the right (pin 1 lower left)
        q = MP.quarter_turns_to(u_plan, (1, 0))
        sv = ST.Step(s.text); sv._flat = True; sv.rotate90("z", q)
        av = MG.analyse(sv, name) if a else None
        pts = [MP._rot([x, y, 0], "z", q) for x, y in ((band[0], band[1]), (band[2], band[3]))]
        bandv = [min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)]
        img, err = render_top(sv.text, av, plan, bandv, worst, base + ".check.png", "front side wall, standard pose" if plan["band"]["sideTurn"] else "standard pose: pin 1 lower left")
        rep["image"] = img
        if err:
            rep["imageError"] = err
    rep["reportPath"] = base + ".check.json"
    json.dump(rep, open(rep["reportPath"], "w"), indent=1)
    if not quiet:
        cap = next((c for c in checks if c["check"] == "size"), {})
        if worst == "PASS" and a:
            print(f"Hint: Mark along {plan['band']['lengthMm']:.2f} mm axis, {a['capMm']:.2f} mm text, upright with pin 1 {plan['pin1']['corner'].replace('/', ' ')}: PASS", file=sys.stderr)
        for c in checks:
            if c["status"] != "PASS":
                print(f"Hint: {c['status']} {c['check']}: {c['detail']}", file=sys.stderr)
        print(f"Hint: {worst}. Look at {rep.get('image')} (top view) and at the outline at widget size before you report.", file=sys.stderr)
    return rep


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("step"); ap.add_argument("--outline"); ap.add_argument("--footprint"); ap.add_argument("--plan"); ap.add_argument("--mpn")
    ap.add_argument("--no-render", action="store_true")
    a = ap.parse_args(argv)
    fp = a.footprint
    if not fp:
        d = os.path.dirname(os.path.abspath(a.step))
        c = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".kicad_mod")]
        if len(c) == 1:
            fp = c[0]
    rep = check(a.step, footprint=fp, outline=a.outline, mpn=a.mpn, plan_path=a.plan, render=not a.no_render)
    print(json.dumps(dict(status=rep["status"], report=rep["reportPath"], image=rep.get("image"), checks=[(c["check"], c["status"]) for c in rep["checks"]])))
    return 1 if rep["status"] == "FAIL" else 0


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