master
John Lauer Publish 1.2.1 8b75307 18d ago
#!/usr/bin/env python3
"""Etch an MPN onto a part STEP the planner's way, on the shared service only (no OpenCascade):

  1. plan (mark_plan.py): face, long axis, usable band, reading direction from pin 1, lines, size;
  2. turn the part (exact quarter turns on the STEP text): stand it up, bring a side face to the top for
     LEDs, and pose it so the reading direction is +X;
  3. POST /laser-etch?mode=flat to service-step2glb on a thin proxy plate laid on the planned band, then
     graft the real model (untouched, colours kept) under Adom.Chip next to the Adom.LaserEtch group;
  4. fit the service's mark into the band: line 2 brought up to line 1's size, the block scaled to fill
     the band minus margins, centred on it and seated on the face (coplanar, 5 um lift);
  5. turn everything back, so the output sits in the input's own frame (board model paths and
     transforms do not change), then run the legibility gate (mark_check.py) on the result."""
import json, 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_geom as MG

LIFT = 0.005
PROXY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")
PROXY_T = 0.05


def _rect_turn(r, q):
    pts = [MP._rot([x, y, 0], "z", q) for x, y in ((r[0], r[1]), (r[2], r[3]))]
    return [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)]


def _set_mark_colour(step, rgb):
    """Recolour the mark (sRGB 0..1) on its styled items; the default is the service's light grey-white."""
    ents = step.product_entities("Adom.LaserEtch") or set()
    for k in step.order:
        if step.kind(k) == "STYLED_ITEM" and set(step.refs(k)) & ents:
            for x in step.reach([k]):
                if step.kind(x) == "COLOUR_RGB":
                    nm = step.body[x].split(",", 1)[0]
                    step.body[x] = nm + "," + ",".join(ST.fmt(v) for v in rgb) + ")"


def etch(src, mpn, out, footprint=None, face="auto", up="auto", side_band="high", along=None, terminations="geometry",
         margin_w=MP.MARGIN_W, margin_h=MP.MARGIN_H, lines=None, over_vendor_mark=False, keep_old_mark=False, mark=None, log=print):
    plan = MP.plan(src, footprint, mpn, face, up, side_band, margin_w, margin_h, along=along, terminations=terminations)
    if plan["vendorMarking"]["present"] and not over_vendor_mark:
        raise SystemExit("ERROR: " + plan["warnings"][0] + "\nHint: leave this part's vendor model as it is; pass --over-vendor-mark only if the user asks for an Adom mark on top.")
    text_lines = lines or plan["text"]["lines"]
    s = ST.Step(src)
    s.flatten()
    removed = []
    for nm in plan["existingMarks"]:
        if not keep_old_mark:
            s.remove_product(nm); removed.append(nm)
    turns = [(t["axis"], t["quarterTurns"]) for t in plan["standUp"]]
    st = plan["band"]["sideTurn"]
    if st:
        turns.append((st["axis"], st["quarterTurns"]))
    qz = plan["etch"]["poseQuarterTurnsZ"]
    if qz:
        turns.append(("z", qz))
    for ax, q in turns:
        s.rotate90(ax, q)
    band = _rect_turn(plan["band"]["regionMm"], qz)
    bz = plan["band"]["zMm"]
    # the service etches a thin PROXY plate laid exactly on the band (any model works, B-spline faces
    # included, and the service can never pick a cap, a lens or a pin-1 dot); the real model is then
    # grafted, untouched, under Adom.Chip
    proxy = ST.Step(PROXY).scale_axes(band[0], band[1], bz - PROXY_T, band[2] - band[0], band[3] - band[1], PROXY_T)
    etched = SVC.laser_etch(proxy.text, text_lines[0], text_lines[1] if len(text_lines) > 1 else None, job=f"etch-{mpn}")
    e = ST.Step(etched)
    e.flatten()
    m = MG.analyse(e, "Adom.LaserEtch")
    if not m:
        raise SystemExit("ERROR: the service returned no Adom.LaserEtch group")
    # 1) the service writes along its own idea of the long axis: turn the mark so it reads +X
    q_mark = MP.quarter_turns_to(m["u"], (1, 0))
    if q_mark:
        e.transform_product("Adom.LaserEtch", m["centre"], 1.0, q_mark)
        m = MG.analyse(e, "Adom.LaserEtch")
    # 2) even out the lines: the service draws line 2 at about 0.62 of line 1
    if len(m["lines"]) == 2:
        l1, l2 = m["lines"]
        k = l1["capMm"] / max(l2["capMm"], 1e-9)
        ents2 = e.faces_subset_entities("Adom.LaserEtch", l2["faces"])
        c2 = [l2["centre"][0], l2["centre"][1], m["z"]]
        gap = MP.GAP * l1["capMm"]
        new_top = l1["box"][1] - gap
        new_h = (l2["box"][3] - l2["box"][1]) * k
        dy = (new_top - new_h / 2) - c2[1]
        dx = l1["centre"][0] - c2[0]
        e.transform_entities(ents2, c2, k, (dx, dy, 0))
        m = MG.analyse(e, "Adom.LaserEtch")
    # 3) fill the band minus margins, centred, seated on the face
    bw, bh = band[2] - band[0], band[3] - band[1]
    mw_, mh_ = m["bbox"][3] - m["bbox"][0], m["bbox"][4] - m["bbox"][1]
    scale = min(bw * (1 - 2 * margin_w) / mw_, bh * (1 - 2 * margin_h) / mh_)
    c = m["centre"]
    target = [(band[0] + band[2]) / 2, (band[1] + band[3]) / 2]
    e.transform_product("Adom.LaserEtch", (c[0], c[1], c[2]), scale, 0, (target[0] - c[0], target[1] - c[1], 0), set_z=bz + LIFT)
    fitted = MG.analyse(e, "Adom.LaserEtch")
    e.graft(s, "Adom.Chip")
    # 4) back to the input's frame
    for ax, q in reversed(turns):
        e.rotate90(ax, -q)
    if mark not in (None, "light"):
        rgb = {"dark": [0.11, 0.11, 0.11]}.get(mark) or [float(v) for v in mark.split(",")]
        _set_mark_colour(e, rgb)
    e.write(out)
    info = dict(out=os.path.abspath(out), mpn=mpn, lines=text_lines, removedOldMarks=removed, turns=[dict(axis=a, quarterTurns=q) for a, q in turns],
                serviceMarkTurnedQuarter=q_mark, line2Scaled=len(text_lines) == 2,
                fit=dict(scale=round(scale, 4), capMm=round(fitted["capMm"], 4), markMm=[round(fitted["bbox"][3] - fitted["bbox"][0], 4), round(fitted["bbox"][4] - fitted["bbox"][1], 4)],
                         bandMm=[round(bw, 4), round(bh, 4)], zMm=round(bz + LIFT, 4)))
    return plan, info


def main(argv=None):
    import argparse
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--in", dest="src", required=True); ap.add_argument("--mpn", required=True); ap.add_argument("--out", required=True)
    ap.add_argument("--footprint", help="the part's .kicad_mod: pin 1 / pad 1, pads and the model rotation (strongly recommended; a single .kicad_mod beside the STEP is used automatically)")
    ap.add_argument("--face", default="auto", choices=["auto", "top", "side"])
    ap.add_argument("--up", default="auto", choices=["auto", "z", "y"])
    ap.add_argument("--side-band", default="high", choices=["high", "low"])
    ap.add_argument("--along", choices=["long", "x", "y"], help="OVERRIDE the planner's axis (x/y in the stood-up frame); warns when it puts the text across the short side")
    ap.add_argument("--terminations", default="geometry", choices=["geometry", "pads"])
    ap.add_argument("--margin-w", type=float, default=MP.MARGIN_W); ap.add_argument("--margin-h", type=float, default=MP.MARGIN_H)
    ap.add_argument("--lines", help="force the text lines, '|' separated (default: the planner's choice)")
    ap.add_argument("--over-vendor-mark", action="store_true"); ap.add_argument("--keep-old-mark", action="store_true")
    ap.add_argument("--mark", default="light", help="light (default, the rule) | dark (only on a light body) | r,g,b sRGB 0..1")
    ap.add_argument("--frac", help=argparse.SUPPRESS); ap.add_argument("--side-dir", help=argparse.SUPPRESS); ap.add_argument("--service", help=argparse.SUPPRESS)
    ap.add_argument("--no-check", action="store_true", help="skip the legibility gate (not recommended)")
    ap.add_argument("--outline", help="also check this outline SVG against the mark")
    a = ap.parse_args(argv)
    fp = a.footprint
    if not fp:
        d = os.path.dirname(os.path.abspath(a.src))
        c = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".kicad_mod")]
        if len(c) == 1:
            fp = c[0]; print(f"Hint: using footprint {fp} (the only .kicad_mod beside the STEP)", file=sys.stderr)
    for flag in ("frac", "side_dir", "service"):
        if getattr(a, flag):
            print(f"Hint: --{flag.replace('_', '-')} is ignored now: the planner sizes the text to the band and picks the front wall from pin 1 (adom-aiflow-etch --legacy keeps the old OpenCascade path)", file=sys.stderr)
    along = None if a.along in (None, "long") else a.along
    plan, info = etch(a.src, a.mpn, a.out, fp, a.face, a.up, a.side_band, along, a.terminations, a.margin_w, a.margin_h,
                      a.lines.split("|") if a.lines else None, a.over_vendor_mark, a.keep_old_mark, a.mark)
    for w in plan["warnings"]:
        print("Hint: WARNING " + w, file=sys.stderr)
    plan_path = os.path.splitext(a.out)[0] + ".mark-plan.json"
    json.dump(dict(plan=plan, etch=info), open(plan_path, "w"), indent=1)
    result = dict(ok=True, out=info["out"], plan=plan_path, summary=plan["summary"], etch=info)
    rc = 0
    if not a.no_check:
        import mark_check as MC
        rep = MC.check(a.out, plan=plan, footprint=fp, outline=a.outline, mpn=a.mpn)
        result["check"] = dict(status=rep["status"], report=rep["reportPath"], image=rep.get("image"))
        rc = 0 if rep["status"] != "FAIL" else 1
    print(json.dumps(result))
    return rc


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