master
John Lauer Datasheet-true STEP height (0.55 mm), side MPN mark re-applied; GLBs, hero rebuilt without Z stretch 2e9a25d 20d ago
#!/usr/bin/env python3
"""Laser-etch a part's MPN onto the top face of its STEP, keeping the model's own colours. The mark is
FLAT by default (each glyph's top face only, a zero-thickness sheet: about a fifth of the triangles of
the service's extruded glyphs; ETCH_SOLID=1 keeps the solids).

The adom-chip-laser service etches across X on a Z-up part and repaints the whole model black and
grey. This stands the part up (--up y for Y-up vendor models), turns it so its LONGEST top-face
direction lies along X, has the service etch that, keeps only the service's inlay, adds it to the
UNTOUCHED original as a separate 'laser_etch' part with its own colour, and turns everything back.
Output is in the original's frame, so the board's model path and transform do not change.

SIDE marks (--face side): for parts whose top must stay clear (an LED's window), the mark goes on a long
side face instead. After standing the part up, the chosen side (--side-dir, default -y: the long side facing
-Y in the seating frame) is turned to face +Z, the flat face band furthest from the seating plane on that side
(the moulded body / lens band, never the seam to the PCB substrate or the leads) is picked by side_region.py,
the service is asked again with frac rescaled so the text fills that band minus margins (--margin-w,
--margin-h), the mark is centred on the band (fit_mark.py), seated on the real surface (seat_mark.py) and all
turns are undone about the same pivot. --mark auto (the side default) picks dark or light by contrast with
that face's own colour.

Usage: etch_part.py --in part.step --mpn MPN --out part-etched.step [--up z|y] [--frac 0.55]
                    [--mark light|dark|auto|r,g,b] [--service URL]
                    [--face top|side] [--side-dir -y|+y|-x|+x] [--margin-w 0.10] [--margin-h 0.18]
Needs OCP (OpenCascade's Python bindings; `adom-aiflow-etch` makes its own venv on first use)."""
import argparse, json, os, subprocess, sys, tempfile, urllib.request
HERE = os.path.dirname(os.path.abspath(__file__)); PY = sys.executable
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="src", required=True); ap.add_argument("--mpn", required=True); ap.add_argument("--out", required=True)
ap.add_argument("--up", default="z", choices=["z", "y"]); ap.add_argument("--frac", default="0.55")
ap.add_argument("--along", default="long", choices=["long", "x", "y"], help="direction the text runs in the stood-up frame: long axis (default), or x/y to keep it off a printed band such as an electrolytic's polarity stripe")
ap.add_argument("--mark", default=None, help="light|dark|auto|r,g,b (linear RGB); default light for top, auto for side")
ap.add_argument("--face", default="top", choices=["top", "side"], help="top (default, the original behaviour) or a long side face")
ap.add_argument("--side-band", default="high", choices=["high", "low"], help="which flat band of the side face gets the mark: high (the lens/body band, default) or low (the body strip under a see-through window that runs the full length, e.g. WS2812B-2020)")
ap.add_argument("--side-dir", default="-y", choices=["-y", "+y", "-x", "+x"], help="which side face gets the mark, in the seating frame")
ap.add_argument("--margin-w", type=float, default=0.10); ap.add_argument("--margin-h", type=float, default=0.18); ap.add_argument("--service", default=os.environ.get("ADOM_CHIP_LASER_URL", "https://laserchip-6dn1tl7vjz5u.adom.cloud"))
a = ap.parse_args()
from OCP.STEPControl import STEPControl_Reader
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
def ext(p):
    r = STEPControl_Reader(); r.ReadFile(p); r.TransferRoots(); b = Bnd_Box(); BRepBndLib.Add_s(r.OneShape(), b)
    mn, mx = b.CornerMin(), b.CornerMax(); return mx.X() - mn.X(), mx.Y() - mn.Y(), mx.Z() - mn.Z()
def run(*args):
    r = subprocess.run([PY, *args], capture_output=True, text=True)
    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")
def runj(*args):
    r = subprocess.run([PY, *args], capture_output=True, text=True)
    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")
    return json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1])   # OCCT writers print banners too
RGB = {"light": "0.78,0.78,0.76", "dark": "0.012,0.012,0.012"}
tmp = tempfile.mkdtemp(prefix="etch-"); cur = a.src; undo = []; info = {}
if a.up == "y":                                     # Y-up vendor model: stand it up first
    run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/up.step", "90", "x"); cur = f"{tmp}/up.step"; undo.append(("-90", "x"))
def etch(model, frac, out):
    req = urllib.request.Request(f"{a.service.rstrip('/')}/etch?mpn={urllib.request.quote(a.mpn)}&frac={frac}", data=open(model, "rb").read(), method="POST")
    with urllib.request.urlopen(req, timeout=300) as resp:
        open(out, "wb").write(resp.read()); return resp.headers.get("X-Etch-Fontsize-Mm")
if a.face == "top":
    dx, dy, dz = ext(cur)
    if (a.along == "long" and dy > dx * 1.02) or a.along == "y":   # text should run along Y: turn Y onto X so the service writes along it
        run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z"); cur = f"{tmp}/long.step"; undo.append(("-90", "z"))
    size = etch(cur, a.frac, f"{tmp}/etched.step")
    rgb = RGB.get(a.mark or "light", a.mark or "light")
    run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched.step", f"{tmp}/merged.step", "0.01", rgb); cur = f"{tmp}/merged.step"
    # the service lays the mark at the bbox top: on an MLCC or resistor that is the end caps, so it floats
    # above the body; seat it on the surface actually under the text
    run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"
else:
    from OCP.STEPControl import STEPControl_Reader as _R
    r_ = _R(); r_.ReadFile(cur); r_.TransferRoots(); b_ = Bnd_Box(); BRepBndLib.Add_s(r_.OneShape(), b_); mn_, mx_ = b_.CornerMin(), b_.CornerMax()
    pv = f"{(mn_.X()+mx_.X())/2},{(mn_.Y()+mx_.Y())/2},{(mn_.Z()+mx_.Z())/2}"          # one pivot for every turn, forward and back
    turn, up = {"-y": (("-90", "x"), (0, 1, 0)), "+y": (("90", "x"), (0, -1, 0)), "-x": (("90", "y"), (1, 0, 0)), "+x": (("-90", "y"), (-1, 0, 0))}[a.side_dir]
    run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/side.step", turn[0], turn[1], pv); cur = f"{tmp}/side.step"; undo.append((str(-float(turn[0])), turn[1], pv))
    dx, dy, dz = ext(cur)
    if (a.along == "long" and dy > dx * 1.02) or a.along == "y":
        run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z", pv); cur = f"{tmp}/long.step"; undo.append(("-90", "z", pv)); up = (-up[1], up[0], up[2])
    reg = runj(os.path.join(HERE, "side_region.py"), cur, "--up", ",".join(map(str, up)), "--pick", a.side_band)
    if a.mark in (None, "auto"):                    # contrast against the face the mark sits on (WCAG-style ratio on linear luminance)
        c = reg["rgb"] or [0.5, 0.5, 0.5]; Y = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
        rgb = RGB["dark"] if (Y + 0.05) / (0.012 + 0.05) >= (0.6 + 0.05) / (Y + 0.05) else RGB["light"]
    else: rgb = RGB.get(a.mark, a.mark)
    region = ",".join(str(v) for v in reg["region"]); frac = float(a.frac); fits = []
    for attempt in range(3):                        # service sizes the text by frac; rescale until it fills the band
        size = etch(cur, frac, f"{tmp}/etched{attempt}.step")
        run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched{attempt}.step", f"{tmp}/merged{attempt}.step", "0.01", rgb)
        fit = runj(os.path.join(HERE, "fit_mark.py"), f"{tmp}/merged{attempt}.step", f"{tmp}/fit{attempt}.step", region, str(a.margin_w), str(a.margin_h))
        fits.append(dict(frac=frac, fontMm=float(size) if size else None, **fit))
        if 1.0 <= fit["scale_to_fill"] <= 1.06: break   # fills the band within 6 %, never overflows
        frac = round(frac * fit["scale_to_fill"] * 0.99, 4)
    x0, y0, x1, y1 = reg["region"]; m = fits[-1]["mark_region"]
    assert x0 <= m[0] and m[2] <= x1 and y0 <= m[1] and m[3] <= y1, f"mark {m} not inside side face {reg['region']}"
    cur = f"{tmp}/fit{len(fits)-1}.step"
    run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"
    info = dict(sideDir=a.side_dir, sideFace=reg, fits=fits, frac=fits[-1]["frac"])
for i, u in enumerate(reversed(undo)):
    nxt = f"{tmp}/undo{i}.step"; run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, nxt, *u); cur = nxt
os.replace(cur, a.out)
print(json.dumps({"ok": True, "out": a.out, "mpn": a.mpn, "face": a.face, "fontMm": float(size) if size else None, "alongLongAxis": a.along == "long", "along": a.along, "turnedForLongAxis": any(u[1] == "z" for u in undo), "stoodUp": a.up == "y", "mark": rgb, **info}))