main
John Lauer Publish 0.2.19 81ffca7
#!/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>] [--mode auto|etched|composed] [--lines "A|B"] [--plan x.mark-plan.json]
[--out-dir DIR] [--styles dark,blueprint,...|all]`.

The name's line split is the mark's: --lines when given, else the etch's own record in <etched>.mark-plan.json
(or --plan), which keeps a forced `adom-aiflow-etch --lines` split, else the planner's default. The split is
recorded on the SVG (data-lines on the name group) so `adom-aiflow-etch check --outline` can compare it.

How (all on the shared service, no OpenCascade): the part is stood up and turned to the planner's standard iso
pose (pin 1 / pins 1 to 3 toward the viewer), the service draws its plain outline, and calibrate() fits that
drawing's scale and offset to the projected model vertices (an orthographic view, so the fit is exact). Then the
name, by --mode:
  etched    the etched model's own mark edges (its Adom.LaserEtch part, top contour only), read from the STEP
            text, posed with the etch's quarter turns, projected with the same iso() and calibration. The name
            is the mark itself: every line the etch carries, at its own size and place. Needs --etched.
  composed  the service draws the name in its single-stroke outline font on a thin plate laid exactly on the
            planner's band, mapped into the part's drawing by inverse projection onto the band plane. For parts
            with no etched model, or a mark too small to read in the widget.
  auto      (default) etched when --etched is given, its mark reads and its outline passes the gate; else
            composed.
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."""
import argparse, html, 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 stroke_attrs(svg_body):
    """The attributes of the drawing's main visible edge stroke (the most common solid, unfiltered class),
    used for the name drawn from the mark's own edges so it matches the style."""
    from collections import Counter
    pl = [t for t in MC._polylines(svg_body) if not t.get("dash") and not t.get("filt")] or MC._polylines(svg_body)
    if not pl:
        return ' fill="none" stroke="#e6edf3" stroke-width="1.0" stroke-linejoin="round" stroke-linecap="round"'
    cls = Counter((t["w"], t["c"]) for t in pl).most_common(1)[0][0]
    at = next(t["attrs"] for t in pl if (t["w"], t["c"]) == cls)
    return " " + at.strip().rstrip("/").strip()


def etched_name(etched, plan, svg_body, verts, e_to_o, to_e, lines, edges=None):
    """The name group drawn from the etched model's own mark edges (issue #89): the etched STEP posed in the
    etch frame (mark face +Z), its top-contour glyph edges mapped to the outline pose (e_to_o), projected with
    the same iso() and calibrated to the service's body drawing (calibrate()). None when the mark does not
    read (no mark product, no edges) or the calibration cannot be trusted."""
    c1 = calibrate(MC._polylines(svg_body), verts)
    if c1["agree"] >= 0.03:
        return None
    if edges is None:
        edges = MG.mark_edges(posed(etched, plan, to_e))
    if not edges:
        return None
    attrs = stroke_attrs(svg_body)
    def m(p):
        q = iso(e_to_o(p)); return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])
    g = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in e) + '"' + attrs + "/>" for e in edges)
    return svg_body.replace("</svg>", '<g id="adom-mark-name" data-source="etched-step-mark-edges" data-lines="'
                            + html.escape("|".join(lines), quote=True) + '">' + g + "</g></svg>")


MODES = ("auto", "etched", "composed")
ETCHED_MODE = "etched: service body outline + the etched model's own mark edges"
COMPOSED_MODE = "composed: service body outline + service single-stroke name on the planned band"


def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, log=lambda m: print(m, file=sys.stderr), write_plain=False, mode="auto"):
    """mode: etched (the name is the etched model's own mark edges; needs `etched`), composed (the service's
    single-stroke name on the planned band) or auto (etched when an etched STEP is given and its mark reads
    and passes the gate, else composed)."""
    if mode not in MODES:
        raise SystemExit(f"ERROR: --mode {mode}: one of {', '.join(MODES)}")
    if mode == "etched" and not etched:
        raise SystemExit("ERROR: --mode etched draws the name from the etched model's own mark: pass --etched <part-etched.step>")
    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
    L = plan["text"]["lines"]
    named_cache = {}
    def named():                                  # the service-drawn name: only when a composed outline is wanted
        if "v" not in named_cache:
            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)
            named_cache["v"] = 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"]
        return named_cache["v"]
    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, edges = None, None
    if etched:
        e = posed(etched, plan, [("z", q_o)]); a = MG.analyse(e)
        if a:
            mk = a["bbox"]; centre_o = a["centre"]
        edges = MG.mark_edges(posed(etched, plan, to_e))
        if not edges:
            (log if mode == "auto" else (lambda m: None))(f"Hint: {os.path.basename(etched)} has no readable mark edges (no Adom.LaserEtch / laser_etch part); the name is composed")
    if mode == "etched" and not edges:
        raise SystemExit(f"ERROR: --mode etched: {os.path.basename(etched)} has no readable mark edges (no Adom.LaserEtch / laser_etch part with faces)\n"
                         "Hint: etch the part first (adom-aiflow-etch --in ... --out ...), or use --mode composed.")

    def compose(style, svg_body):
        svg_name = named().get(style + "-named")
        if not svg_name:
            return None
        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 not (txt and c2 and c1["agree"] < 0.03 and c2["agree"] < 0.03):
            return None
        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)
        return svg_body.replace("</svg>", '<g id="adom-mark-name" data-lines="' + html.escape("|".join(L), quote=True) + '">' + extra + "</g></svg>")

    def gate(path):
        return MC.check_outline(path, plan, centre_o, meta_p, mk, verts=verts)

    written, modes = [], []
    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)
        path = os.path.join(out_dir, f"{stem}-3d-outline-{style}-named.svg")
        out_svg, used, rep = None, None, None
        if mode in ("etched", "auto") and edges:
            out_svg = etched_name(etched, plan, svg_body, verts, e_to_o, to_e, L, edges)
            if out_svg is None:
                log(f"Hint: WARNING could not calibrate the {style} drawing for the etched mark edges" + ("; trying the composed name" if mode == "auto" else ""))
            else:
                used = ETCHED_MODE
                open(path, "w").write(out_svg)
                rep = gate(path)
                if rep["status"] == "FAIL" and mode == "auto":
                    log(f"Hint: the etched mark's own edges fail the outline gate ({'; '.join(rep.get('hints', []))}); trying the composed name")
                    out_svg, used, rep = None, None, None
        if out_svg is None and mode != "etched":
            out_svg = compose(style, svg_body)
            if out_svg is None:
                log(f"Hint: WARNING could not compose the {style} name (calibration)" + ("; pass --etched <part-etched.step> to draw it from the etched model's own mark" if not etched else ""))
                continue
            used = COMPOSED_MODE
            open(path, "w").write(out_svg)
            rep = gate(path)
        if out_svg is None:
            continue
        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", [])))
        modes.append(used)
        written.append(dict(style=style, path=path, gate=rep, mode=used))
    plan_path = os.path.join(out_dir, f"{stem}.mark-plan.json")
    json.dump(plan, open(plan_path, "w"), indent=1)
    used_modes = list(dict.fromkeys(modes))
    return dict(mode=" / ".join(used_modes) if used_modes else ("etched" if mode == "etched" else COMPOSED_MODE), plan=plan_path, outlines=written)


def resolve_lines(lines_arg=None, etched=None, plan_path=None, log=lambda m: print(m, file=sys.stderr)):
    """(lines, source): --lines, else the etch's sidecar (--plan, or <etched>.mark-plan.json), else (None, None)."""
    side = plan_path or (os.path.splitext(etched)[0] + ".mark-plan.json" if etched else None)
    recorded = None
    if side and os.path.exists(side):
        recorded = MC.mark_lines(None, json.load(open(side))) or None
    if lines_arg:
        got = lines_arg.split("|")
        if recorded and recorded != got:
            log(f"Hint: WARNING --lines {' / '.join(got)} differs from the etch's own lines {' / '.join(recorded)} ({side}); the outline will not match the model's mark")
        return got, "--lines"
    if recorded:
        return recorded, side
    return None, None


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("--lines", help="the name's text lines, '|' separated, as given to the etch (default: the etch's own <etched>.mark-plan.json, else the planner's)")
    ap.add_argument("--plan", help="the etch's plan/check sidecar (<out>.mark-plan.json) to read the line split from (default: beside --etched)")
    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("--mode", default="auto", choices=list(MODES),
                    help="etched: draw the name from the etched model's own mark edges (needs --etched); composed: the service's single-stroke "
                         "name on the planned band; auto (default): etched when --etched is given and its mark reads and passes the gate, else composed")
    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)
    lines, src = resolve_lines(a.lines, a.etched, a.plan)
    mode = a.mode
    if mode == "auto" and a.etched and src == "--lines" and resolve_lines(None, a.etched, a.plan)[0] not in (None, lines):
        print("Hint: --lines differs from the etch's own lines, so the etched mark's edges cannot be the name; composing it", file=sys.stderr)
        mode = "composed"
    if lines:
        if lines != plan["text"]["lines"]:
            print(f"Hint: name lines {' / '.join(lines)} from {src} (the planner's default is {' / '.join(plan['text']['lines'])})", file=sys.stderr)
        plan["text"]["lines"] = lines
    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, mode=mode)
    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"], mode=o["mode"],
                                                                                         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())