master
John Lauer Publish 1.2.1 8b75307 18d ago
#!/usr/bin/env python3
"""Named chip outline that IS the model's mark: `adom-aiflow-etch outline <part.step> [--footprint fp.kicad_mod]
[--mpn MPN] [--etched <x-etched.step>] [--out-dir DIR] [--styles dark,blueprint,...|all]`.

How (all on the shared service, no OpenCascade):
  1. the part is stood up and turned to the planner's standard iso pose (pin 1 / pins 1 to 3 toward the
     viewer) and the service draws its plain outline;
  2. the service draws the name, in its single-stroke outline font, on a thin plate laid exactly on the
     planner's band (between the terminations, clear of the pin-1 dot, on the LED's front wall), in the
     frame where the text reads +X;
  3. both drawings are orthographic iso views of the same frame, so the name strokes are mapped exactly
     (inverse projection onto the band plane, the same quarter turns as the etch, projection into the
     part's drawing). The name runs along the long axis, reads upright, sits where the etched mark sits.
Every SVG is gated (mark_check.check_outline). A failing SVG is written as *.FAILED-GATE.svg with a loud
warning and Hint lines, never silently. If the calibration cannot be trusted, the outline of the posed
ETCHED STEP is used instead (the mark's own edges are the name)."""
import argparse, json, math, os, 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_check as MC
import mark_geom as MG

PLATE_T = 0.01
iso, iso_inv, vertices, calibrate = MG.iso, MG.iso_inv, MG.vertices, MG.calibrate


def frames(plan):
    """Quarter turns: stand-up frame -> etch frame (mark face +Z, reading +X) and -> outline pose."""
    to_e = []
    if plan["band"]["sideTurn"]:
        to_e.append((plan["band"]["sideTurn"]["axis"], plan["band"]["sideTurn"]["quarterTurns"]))
    if plan["etch"]["poseQuarterTurnsZ"]:
        to_e.append(("z", plan["etch"]["poseQuarterTurnsZ"]))
    q_o = plan["outline"]["poseRotZDeg"] // 90
    def e_to_o(p):
        for ax, q in reversed(to_e):
            p = MP._rot(p, ax, -q)
        return MP._rot(p, "z", q_o)
    return to_e, q_o, e_to_o


def posed(path, plan, extra=()):
    s = ST.Step(path); s.flatten()
    for t in plan["standUp"]:
        s.rotate90(t["axis"], t["quarterTurns"])
    for ax, q in extra:
        s.rotate90(ax, q)
    return s


def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, log=lambda m: print(m, file=sys.stderr), write_plain=False):
    stem = stem or plan["mpn"]
    to_e, q_o, e_to_o = frames(plan)
    body_step = posed(step, plan, [("z", q_o)])
    for nm in [n for _, n in body_step.products() if n in MG.MARK_NAMES]:
        body_step.remove_product(nm)              # an old mark must not show through
    body = SVC.outline(body_step.text, job=f"outline-{stem}-body")["outlines"]
    # the name, on a plate laid on the band in the etch frame (reading +X, rot 0)
    b = plan["band"]["regionMm"]; bz = plan["band"]["zMm"]
    qz = plan["etch"]["poseQuarterTurnsZ"]
    pts = [MP._rot([x, y, 0], "z", qz) for x, y in ((b[0], b[1]), (b[2], b[3]))]
    x0, y0 = min(p[0] for p in pts), min(p[1] for p in pts); x1, y1 = max(p[0] for p in pts), max(p[1] for p in pts)
    mw, mh = plan["band"]["margins"]["lengthEachSide"], plan["band"]["margins"]["heightEachSide"]
    # the plate is the text block's area (band minus margins), so the name has the etched mark's size
    px0, px1 = x0 + mw * (x1 - x0) * 0.5, x1 - mw * (x1 - x0) * 0.5
    py0, py1 = y0 + mh * (y1 - y0) * 0.5, y1 - mh * (y1 - y0) * 0.5
    plate = ST.Step(os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")).scale_axes(px0, py0, bz - PLATE_T, px1 - px0, py1 - py0, PLATE_T)
    L = plan["text"]["lines"]
    named = SVC.outline(plate.text, name=L[0], line2=L[1] if len(L) == 2 else None, rot=0, suffix="-named", job=f"outline-{stem}-name")["outlines"]
    plate_pts = [[x, y, z] for x in (px0, px1) for y in (py0, py1) for z in (bz - PLATE_T, bz)]
    verts = vertices(body_step)
    os.makedirs(out_dir, exist_ok=True)
    meta_p = SVC.step_meta(body_step.text, "outline-check-meta")
    centre_e = [(px0 + px1) / 2, (py0 + py1) / 2, bz]
    centre_o = e_to_o(centre_e)
    mk = None
    if etched:
        e = posed(etched, plan, [("z", q_o)]); a = MG.analyse(e)
        if a:
            mk = a["bbox"]; centre_o = a["centre"]
    written, mode = [], "composed: service body outline + service single-stroke name on the planned band"
    for style, svg_body in body.items():
        if styles and style not in styles:
            continue
        if write_plain:      # the plain outline in the same standard pose
            open(os.path.join(out_dir, f"{stem}-3d-outline-{style}.svg"), "w").write(svg_body)
        svg_name = named.get(style + "-named")
        out_svg = None
        if svg_name:
            txt, plate_body = MC.outline_text(svg_name, plate=True)
            c1 = calibrate(MC._polylines(svg_body), verts)
            c2 = calibrate(plate_body, plate_pts) if plate_body else None
            if txt and c2 and c1["agree"] < 0.03 and c2["agree"] < 0.03:
                def m(p):
                    X = (p[0] - c2["o"][0]) / c2["s"]; Y = (p[1] - c2["o"][1]) / c2["s"]
                    q = iso(e_to_o(iso_inv(X, Y, bz)))
                    return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])
                extra = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in t["pts"]) + '"' + t["attrs"].rstrip("/").rstrip() + "/>" for t in txt)
                out_svg = svg_body.replace("</svg>", '<g id="adom-mark-name">' + extra + "</g></svg>")
        if out_svg is None:
            if not etched:
                log(f"Hint: WARNING could not compose the {style} name (calibration); pass --etched <part-etched.step> to draw the outline from the etched model")
                continue
            e = posed(etched, plan, [("z", q_o)])
            out_svg = SVC.outline(e.text, job=f"outline-{stem}-etched")["outlines"][style]
            mode = "etched STEP outline (the mark's own edges)"
        path = os.path.join(out_dir, f"{stem}-3d-outline-{style}-named.svg")
        open(path, "w").write(out_svg)
        rep = MC.check_outline(path, plan, centre_o, meta_p, mk, verts=verts)
        if rep["status"] == "FAIL":
            bad = path.replace("-named.svg", "-named.FAILED-GATE.svg")
            os.replace(path, bad); path = bad
            log(f"Hint: FAIL outline {os.path.basename(path)}: " + "; ".join(rep.get("hints", [])))
        written.append(dict(style=style, path=path, gate=rep))
    plan_path = os.path.join(out_dir, f"{stem}.mark-plan.json")
    json.dump(plan, open(plan_path, "w"), indent=1)
    return dict(mode=mode, plan=plan_path, outlines=written)


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("step"); ap.add_argument("--etched"); ap.add_argument("--footprint"); ap.add_argument("--mpn")
    ap.add_argument("--out-dir", default="."); ap.add_argument("--styles", default="dark", help="comma list, or 'all'")
    ap.add_argument("--face", default="auto", choices=["auto", "top", "side"]); ap.add_argument("--up", default="auto", choices=["auto", "z", "y"])
    ap.add_argument("--stem")
    ap.add_argument("--write-plain", action="store_true", help="also write the plain outlines (<stem>-3d-outline-<style>.svg) in the same pose")
    ap.add_argument("--posed-out", help="also write the STEP in the outline's standard pose (the etched one when given), for a shaded iso render with the same view")
    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]
    mpn = a.mpn or os.path.splitext(os.path.basename(a.step))[0].upper()
    plan = MP.plan(a.step, fp, mpn, a.face, a.up)
    r = outlines(a.step, plan, a.etched, a.out_dir, None if a.styles == "all" else tuple(a.styles.split(",")), a.stem, write_plain=a.write_plain)
    if a.posed_out:
        posed(a.etched or a.step, plan, [("z", plan["outline"]["poseRotZDeg"] // 90)]).write(a.posed_out)
    worst = "FAIL" if any(o["gate"]["status"] == "FAIL" for o in r["outlines"]) else ("PASS" if r["outlines"] else "FAIL")
    print(f"Hint: {plan['summary']}", file=sys.stderr)
    print(f"Hint: outline {worst} ({r['mode']}); look at it at 320 px before you report.", file=sys.stderr)
    print(json.dumps(dict(status=worst, mode=r["mode"], plan=r["plan"], outlines=[dict(path=o["path"], status=o["gate"]["status"],
                                                                                         capPx=o["gate"].get("capHeightPx320"), offsetPct=o["gate"].get("offsetPct")) for o in r["outlines"]])))
    return 1 if worst == "FAIL" else 0


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