master
John Lauer Publish 1.2.1 8b75307 18d ago
#!/usr/bin/env python3
"""MARK PLANNER: where an MPN laser mark goes on a part, decided by construction.

THE RULE (John, 2026-10-03):
  * the mark runs ALONG THE LONGEST usable dimension of the marking face (chip R/C/L: along the body,
    between the metal terminations; ICs: parallel to the pin rows; side-marked LEDs: the long side wall);
  * as large as fits: the usable band minus margins (12 % of the length, 15 % of the height each side),
    one line, two lines only when that makes BOTH lines larger;
  * reads upright, left to right, in the standard pose: pin 1 / pad 1 lower left seen from the top
    (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode), equivalently the iso
    outline shows pin 1 (pins 1 to 3) toward the viewer and the text reads without tilting your head;
  * flat light mark coplanar with the face (Adom.Chip / Adom.LaserEtch); LED windows stay clear (side);
  * the 2-D chip outline's name is the same mark: same face, same direction, same position.

Input: STEP (+ footprint .kicad_mod for pin 1 / pads / the model transform) (+ --face top|side|auto).
Geometry comes from the shared service (/step-meta); no OpenCascade here.

Frames. The plan is stated in the FOOTPRINT frame: X right, Y up (KiCad's footprint Y flipped), Z up
off the board, after the footprint's own model rotation (or the STEP's up-axis hint) stands the part up.
`standUp` lists the quarter turns that take the raw STEP there.

Usage: mark_plan.py <part.step> [--footprint fp.kicad_mod] [--mpn MPN] [--face top|side|auto] [--json out.json]"""
import argparse, 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

SCHEMA = "adom/mark-plan@1"
# Glyph metrics of the service font (service-step2glb /laser-etch), measured 2026-10-03 on a 4 x 2 mm box:
# advance per character = 0.82 cap heights, minus 0.18 of a cap for the last glyph's side bearing;
# two lines are set at the same cap height, 0.30 caps apart (the etch scales the service's smaller line 2 up).
ADV, TAIL, GAP = 0.82, 0.18, 0.30
SERVICE_L2 = 0.62             # the service draws line 2 at 0.62 of line 1; adom-aiflow-etch evens them out
MARGIN_W, MARGIN_H = 0.12, 0.15
WIDGET_PX = 320
TWO_LINE_GAIN = 1.15          # both lines must be at least this much larger than the one-line mark


# ---------------------------------------------------------------- footprint -----------------------------
def _sexp(text):
    tok = re.findall(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()]+', text)
    stack, cur = [], []
    for t in tok:
        if t == "(":
            stack.append(cur); cur = []
        elif t == ")":
            done = cur; cur = stack.pop(); cur.append(done)
        else:
            cur.append(t[1:-1] if t.startswith('"') else t)
    return cur[0] if cur else []


def _find(node, key):
    return [c for c in node if isinstance(c, list) and c and c[0] == key]


def parse_footprint(path):
    root = _sexp(open(path, encoding="utf-8", errors="replace").read())
    name = root[1] if len(root) > 1 and isinstance(root[1], str) else os.path.basename(path)
    pads = []
    for p in _find(root, "pad"):
        at = (_find(p, "at") or [["at", "0", "0"]])[0]
        size = (_find(p, "size") or [["size", "0", "0"]])[0]
        x, y = float(at[1]), float(at[2]); rot = float(at[3]) if len(at) > 3 else 0.0
        w, h = float(size[1]), float(size[2])
        if abs(round(rot / 90) * 90 - rot) < 1e-6 and int(round(rot / 90)) % 2:
            w, h = h, w
        pads.append(dict(num=str(p[1]), x=x, y=-y, w=w, h=h))      # 3-D frame: Y up
    model = None
    for m in _find(root, "model"):
        def xyz(key):
            n = _find(m, key)
            if not n:
                return [0.0, 0.0, 0.0]
            v = _find(n[0], "xyz")
            return [float(t) for t in v[0][1:4]] if v else [0.0, 0.0, 0.0]
        model = dict(path=m[1], offset=xyz("offset"), rotate=xyz("rotate"), scale=xyz("scale"))
        break
    descr = " ".join(" ".join(str(t) for t in n[1:]) for k in ("descr", "tags") for n in _find(root, k))
    return dict(name=name, pads=pads, model=model, path=path, descr=descr)


def pin1(fp):
    if not fp or not fp["pads"]:
        return None
    by = {p["num"]: p for p in fp["pads"]}
    for k in ("1", "A1", "a1", "K", "C"):
        if k in by:
            return by[k]
    nums = [p for p in fp["pads"] if p["num"].isdigit()]
    return min(nums, key=lambda p: int(p["num"])) if nums else fp["pads"][0]


# ---------------------------------------------------------------- geometry helpers ----------------------
def stand_up_turns(fp, meta_raw, up):
    """Quarter turns (axis, q) that take the raw STEP into the footprint frame, Z up."""
    if up in ("z", "y"):
        return ([("x", 1)] if up == "y" else []), f"--up {up}"
    if fp and fp.get("model") and any(abs(r) > 1e-6 for r in fp["model"]["rotate"]):
        rx, ry, rz = fp["model"]["rotate"]
        turns = []
        for ax, r in (("x", rx), ("y", ry), ("z", rz)):        # KiCad applies Rz(-rz) Ry(-ry) Rx(-rx)
            qq = -r / 90.0
            if abs(qq - round(qq)) > 1e-6:
                raise SystemExit(f"mark_plan: footprint model rotation {fp['model']['rotate']} is not a multiple of 90 deg")
            if int(round(qq)) % 4:
                turns.append((ax, int(round(qq)) % 4))
        return turns, "footprint model rotate"
    hint = (meta_raw or {}).get("up_axis_hint", "z")
    if hint == "y":
        return [("x", 1)], "STEP up-axis hint (Y-up model stood up)"
    return [], "as-is (Z up)"


def _rot(v, axis, q):
    x, y, z = v
    for _ in range(q % 4):
        if axis == "z": x, y = -y, x
        elif axis == "x": y, z = -z, y
        elif axis == "y": z, x = -x, z
    return [x, y, z]


def quarter_turns_to(u, target=(1, 0)):
    """Quarter turns about Z that take in-plane unit vector u onto target."""
    for q in range(4):
        x, y, _ = _rot([u[0], u[1], 0], "z", q)
        if abs(x - target[0]) < 1e-6 and abs(y - target[1]) < 1e-6:
            return q
    raise ValueError(u)


def angle_deg(u):
    return int(round(math.degrees(math.atan2(u[1], u[0])))) % 360


def text_fit(n_chars, length, height, mw=MARGIN_W, mh=MARGIN_H):
    """Cap height (mm) of a one-line mark of n characters filling a band length x height minus margins."""
    Lu, Hu = length * (1 - 2 * mw), height * (1 - 2 * mh)
    return max(0.0, min(Hu, Lu / max(ADV * n_chars - TAIL, 0.5)))


def split_points(mpn):
    """Natural places to break an MPN into two lines: after '-', '/', '_' or at a letter/digit change."""
    pts = []
    for i in range(1, len(mpn)):
        a, b = mpn[i - 1], mpn[i]
        if a in "-/_ " or (a.isalpha() != b.isalpha() and a.isalnum() and b.isalnum()):
            pts.append(i)
    return pts or list(range(1, len(mpn)))


def lines_plan(mpn, length, height, mw=MARGIN_W, mh=MARGIN_H):
    n = len(mpn)
    h1 = text_fit(n, length, height, mw, mh)
    one = dict(lines=[mpn], capHeightMm=round(h1, 4), smallestLineMm=round(h1, 4))
    Lu, Hu = length * (1 - 2 * mw), height * (1 - 2 * mh)
    best = None
    for i in split_points(mpn):
        a, b = mpn[:i].rstrip(" "), mpn[i:].lstrip(" ")
        if not a or not b:
            continue
        w = ADV * max(len(a), len(b)) - TAIL
        h = min(Hu / (2 + GAP), Lu / max(w, 0.5))
        cand = dict(lines=[a, b], capHeightMm=round(h, 4), smallestLineMm=round(h, 4))
        if best is None or cand["smallestLineMm"] > best["smallestLineMm"]:
            best = cand
    if best and best["smallestLineMm"] >= TWO_LINE_GAIN * h1:
        best["why"] = f"two lines: the smaller line ({best['smallestLineMm']:.3f} mm) beats one line ({h1:.3f} mm) by {best['smallestLineMm'] / max(h1, 1e-9):.2f}x"
        return best, one
    one["why"] = "one line" + (f" (two lines would give {best['smallestLineMm']:.3f} mm on the smaller line, not {TWO_LINE_GAIN:.2f}x larger)" if best else "")
    return one, best


def iso_scale_px(meta, px=WIDGET_PX, fill=0.9):
    """Pixels per mm when the part's iso outline is drawn in a px-wide box (camera from +X+Y+Z)."""
    pts = []
    for f in meta["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]):
                    pts.append(((-x + y) / math.sqrt(2), (-x - y + 2 * z) / math.sqrt(6)))
    if not pts:
        return 0.0
    w = max(p[0] for p in pts) - min(p[0] for p in pts); h = max(p[1] for p in pts) - min(p[1] for p in pts)
    return px * fill / max(w, h, 1e-6)


def iso_cap_px(cap_mm, u, scale_px, face="top"):
    """On-screen cap height of text whose baseline is u (top face) in the iso view."""
    if face == "top":
        v = (-u[1], u[0], 0.0)
    else:
        v = (0.0, 0.0, 1.0)
    sx = (-v[0] + v[1]) / math.sqrt(2); sy = (-v[0] - v[1] + 2 * v[2]) / math.sqrt(6)
    return cap_mm * scale_px * math.hypot(sx, sy)


# ---------------------------------------------------------------- the planner ---------------------------
MARK_PRODUCTS = ("laser_etch", "Adom.LaserEtch")


def _faces(meta, exclude_products):
    return [f for f in meta["faces"] if f["product_id"] not in exclude_products]


def _bbox_of(faces):
    b = [min(f["bbox_mm"][0] for f in faces), min(f["bbox_mm"][1] for f in faces), min(f["bbox_mm"][2] for f in faces),
         max(f["bbox_mm"][3] for f in faces), max(f["bbox_mm"][4] for f in faces), max(f["bbox_mm"][5] for f in faces)]
    return b


def _planar_up(f, tol=0.99):
    """A flat face lying in a horizontal plane. Many vendor models store flat faces as B-spline surfaces
    (no normal from the service), so a face whose bbox has no Z thickness counts too."""
    b = f["bbox_mm"]
    if b[5] - b[2] >= 2e-3 or (b[3] - b[0]) < 1e-3 or (b[4] - b[1]) < 1e-3:
        return False
    n = f.get("normal")
    return n is None or n[2] > tol


def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry"):
    faces = _faces(meta, mark_ids)
    bb = _bbox_of(faces)
    cx, cy = (bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2
    H = bb[5] - bb[2]
    cand = [f for f in faces if _planar_up(f) and f["bbox_mm"][5] >= bb[5] - max(0.35 * H, 0.05)]
    if not cand:
        raise SystemExit("mark_plan: no flat upward face near the top of the part; is it standing up? (pass --up y for a Y-up model)")
    def contains(f, x, y, pad=1e-6):
        b = f["bbox_mm"]; return b[0] - pad <= x <= b[3] + pad and b[1] - pad <= y <= b[4] + pad
    hit = [f for f in cand if contains(f, cx, cy)]
    if hit:
        face = max(hit, key=lambda f: (round(f["bbox_mm"][5], 4), f["area_mm2"]))
        why = "highest flat face over the body centre"
    else:
        top = max(f["bbox_mm"][5] for f in cand)
        face = max([f for f in cand if f["bbox_mm"][5] >= top - 0.1 * H], key=lambda f: f["area_mm2"])
        why = "largest flat face near the top (none covers the centre)"
    z = face["bbox_mm"][5]
    x0, y0, _, x1, y1, _ = face["bbox_mm"]
    long_x = (x1 - x0) >= (y1 - y0)
    trims, keepouts = [], []
    # terminations / raised features at the ends: faces at or above the band, overlapping it at an end
    fa = face["area_mm2"]
    glyphs = [f for f in faces if f is not face and f["area_mm2"] < 0.005 * fa and abs(f["bbox_mm"][5] - z) < 0.02
              and x0 <= (f["bbox_mm"][0] + f["bbox_mm"][3]) / 2 <= x1 and y0 <= (f["bbox_mm"][1] + f["bbox_mm"][4]) / 2 <= y1]
    for f in cand:
        if f is face or f in glyphs:
            continue
        b = f["bbox_mm"]
        if b[5] < z - 1e-4:
            continue
        if b[3] <= x0 or b[0] >= x1 or b[4] <= y0 or b[1] >= y1:
            continue
        cross = (min(b[4], y1) - max(b[1], y0)) / max(y1 - y0, 1e-9) if long_x else (min(b[3], x1) - max(b[0], x0)) / max(x1 - x0, 1e-9)
        if f["area_mm2"] < 0.02 * fa or cross < 0.5:       # only a cap / raised band across the face trims it
            continue
        mx_, my_ = (x0 + x1) / 2, (y0 + y1) / 2
        if b[0] <= mx_ <= b[3] and b[1] <= my_ <= b[4]:     # covers the middle: a coincident face, not an end cap
            continue
        if long_x:
            if (b[0] + b[3]) / 2 < (x0 + x1) / 2: x0 = max(x0, b[3]); trims.append(("start", b))
            else: x1 = min(x1, b[0]); trims.append(("end", b))
        else:
            if (b[1] + b[4]) / 2 < (y0 + y1) / 2: y0 = max(y0, b[4]); trims.append(("start", b))
            else: y1 = min(y1, b[1]); trims.append(("end", b))
    # small features inside the band (pin-1 dot, polarity notch): keep the text clear of them
    area = (x1 - x0) * (y1 - y0)
    for f in faces:
        if f is face or f in glyphs or f in cand and f["area_mm2"] > 0.05 * area:
            continue
        b = f["bbox_mm"]
        if not (b[2] >= z - 0.25 and b[5] <= z + 1e-3 and b[5] >= z - 0.25):
            continue
        fx, fy = (b[0] + b[3]) / 2, (b[1] + b[4]) / 2
        if not (x0 < fx < x1 and y0 < fy < y1):
            continue
        if f["area_mm2"] > 0.08 * area or (b[3] - b[0]) > 0.4 * (x1 - x0) and (b[4] - b[1]) > 0.4 * (y1 - y0):
            continue
        keepouts.append([round(v, 4) for v in (b[0], b[1], b[3], b[4])])
    term_src = "geometry" if trims else None
    if two_terminal and not trims and pads and terminations == "pads":
        # one face spans the whole top: keep the text between the pads' inner edges (the terminations)
        span = (x1 - x0) if long_x else (y1 - y0)
        full = (bb[3] - bb[0]) if long_x else (bb[4] - bb[1])
        if span > 0.9 * full:
            if long_x:
                inner0 = max(p["x"] + p["w"] / 2 for p in pads if p["x"] < cx); inner1 = min(p["x"] - p["w"] / 2 for p in pads if p["x"] > cx)
                if inner1 - inner0 > 0.2 * span: x0, x1 = max(x0, inner0), min(x1, inner1); term_src = "footprint pads"
            else:
                inner0 = max(p["y"] + p["h"] / 2 for p in pads if p["y"] < cy); inner1 = min(p["y"] - p["h"] / 2 for p in pads if p["y"] > cy)
                if inner1 - inner0 > 0.2 * span: y0, y1 = max(y0, inner0), min(y1, inner1); term_src = "footprint pads"
    # a keep-out near an end shortens the band at that end
    for k in keepouts:
        if long_x:
            mid = (x0 + x1) / 2; kc = (k[0] + k[2]) / 2
            if kc < mid and k[2] - x0 < 0.35 * (x1 - x0): x0 = max(x0, k[2] + 0.02)
            elif kc >= mid and x1 - k[0] < 0.35 * (x1 - x0): x1 = min(x1, k[0] - 0.02)
        else:
            mid = (y0 + y1) / 2; kc = (k[1] + k[3]) / 2
            if kc < mid and k[3] - y0 < 0.35 * (y1 - y0): y0 = max(y0, k[3] + 0.02)
            elif kc >= mid and y1 - k[1] < 0.35 * (y1 - y0): y1 = min(y1, k[1] - 0.02)
    # merge keep-outs that overlap (a dimple is several faces)
    merged = []
    for k in sorted(keepouts):
        if merged and k[0] <= merged[-1][2] and k[1] <= merged[-1][3] and k[3] >= merged[-1][1]:
            m = merged[-1]; merged[-1] = [min(m[0], k[0]), min(m[1], k[1]), max(m[2], k[2]), max(m[3], k[3])]
        else:
            merged.append(list(k))
    return dict(region=[x0, y0, x1, y1], z=z, faceId=face["face_id"], why=why, terminations=term_src,
                trimmed=[t[0] for t in trims], keepouts=merged, bodyBox=bb, vendorGlyphFaces=len(glyphs))


def side_band(meta, mark_ids, up_vec, pick="high"):
    """In a frame where the chosen side already faces +Z: the flat band of that side furthest along the
    part's own up (the lens/body band above the substrate and leads), as side_region.py did with OCC."""
    faces = _faces(meta, mark_ids)
    top = max(f["bbox_mm"][5] for f in faces)
    cand = [f for f in faces if _planar_up(f) and top - f["bbox_mm"][5] < 2e-3 and f["area_mm2"] > 1e-6]
    if not cand:
        raise SystemExit("mark_plan: the chosen side has no flat face")
    big = max(f["area_mm2"] for f in cand)
    cand = [f for f in cand if f["area_mm2"] >= 0.25 * big]
    cen = lambda f: [(f["bbox_mm"][0] + f["bbox_mm"][3]) / 2, (f["bbox_mm"][1] + f["bbox_mm"][4]) / 2]
    f = (max if pick == "high" else min)(cand, key=lambda f: cen(f)[0] * up_vec[0] + cen(f)[1] * up_vec[1])
    b = f["bbox_mm"]
    return dict(region=[b[0], b[1], b[3], b[4]], z=b[5], faceId=f["face_id"], why=f"flat {pick} band of the side wall",
                terminations=None, trimmed=[], keepouts=[], bodyBox=_bbox_of(faces),
                candidates=[[round(v, 4) for v in (c["bbox_mm"][0], c["bbox_mm"][1], c["bbox_mm"][3], c["bbox_mm"][4])] for c in cand])


def is_led(fp, meta, mpn, step_products=()):
    names = " ".join([fp["name"] if fp else "", fp.get("descr", "") if fp else "", *(p["name"] for p in meta.get("products", [])), *step_products])
    return bool(re.search(r"(^|[^A-Z])LED([^A-Z]|$)", names, re.I))


def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick="high", mw=MARGIN_W, mh=MARGIN_H,
         along=None, terminations="geometry", log=None):
    log = log or (lambda m: None)
    fp = parse_footprint(footprint) if footprint else None
    raw = ST.Step(step_path)
    marks = [n for _, n in raw.products() if n in MARK_PRODUCTS]
    meta_raw = None
    if up == "auto" and not (fp and fp.get("model") and any(abs(r) > 1e-6 for r in fp["model"]["rotate"])):
        meta_raw = SVC.step_meta(raw.text, "mark-plan-meta-raw")
    turns, up_src = stand_up_turns(fp, meta_raw, up)
    s = ST.Step(step_path)
    s.flatten()
    for nm in marks:                       # plan on the bare part: an old mark is not part of the body
        s.remove_product(nm)
    for ax, qq in turns:
        s.rotate90(ax, qq)
    meta = SVC.step_meta(s.text, "mark-plan-meta")
    mark_ids = [p["id"] for p in meta.get("products", []) if p["name"] in MARK_PRODUCTS]
    off = fp["model"]["offset"] if fp and fp.get("model") else [0, 0, 0]
    pads = [dict(p, x=p["x"] - off[0], y=p["y"] - off[1]) for p in (fp["pads"] if fp else [])]
    p1 = pin1(dict(pads=pads)) if pads else None
    nums = set(p["num"] for p in pads)
    two_terminal = len(nums) == 2
    faces = _faces(meta, mark_ids)
    bb = _bbox_of(faces)
    cx, cy = (bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2
    hints, warns = [], []
    if face == "auto":
        face = "side" if is_led(fp, meta, mpn, [n for _, n in raw.products()]) else "top"
        if face == "side":
            hints.append("LED detected (footprint/model name): the mark goes on the front long side wall, the window stays clear; --face top overrides")
    if marks:
        warns.append(f"the STEP already carries a mark ({', '.join(marks)}); etch from the plain model, or let adom-aiflow-etch replace it (it does by default)")
    # ---- axis from the top face (the footprint's view) ----
    band_top = top_band(meta, mark_ids, pads, (p1["x"], p1["y"]) if p1 else None, two_terminal, terminations)
    x0, y0, x1, y1 = band_top["region"]
    vendor_mark = band_top["vendorGlyphFaces"] >= 8
    if vendor_mark:
        warns.append(f"the vendor model already carries a printed marking ({band_top['vendorGlyphFaces']} glyph faces on the top): keep the vendor model and do not etch over it (adom-aiflow-etch refuses unless --over-vendor-mark)")
    Lx, Ly = x1 - x0, y1 - y0
    if two_terminal and p1:
        p2 = next(p for p in pads if p["num"] != p1["num"])
        axis = "x" if abs(p2["x"] - p1["x"]) >= abs(p2["y"] - p1["y"]) else "y"
        axis_why = "two-terminal part: along the body, pad 1 to pad 2 (between the terminations)"
        full_len = (bb[3] - bb[0]) if axis == "x" else (bb[4] - bb[1])
        if not band_top["terminations"] and (Lx if axis == "x" else Ly) > 0.97 * full_len:
            hints.append("one flat face covers the whole top: if this part's terminations wrap onto the top (chip R/C/L), rerun with --terminations pads")
    elif abs(Lx - Ly) / max(Lx, Ly) < 0.08 and p1 and len(pads) > 2:
        # square body: run along the pin-1 row
        p2 = next((p for p in pads if p["num"] == "2"), None)
        axis = "x" if p2 and abs(p2["x"] - p1["x"]) > abs(p2["y"] - p1["y"]) else "y"
        axis_why = "square body: along the pin-1 row"
    else:
        axis = "x" if Lx >= Ly else "y"
        axis_why = f"longest usable top dimension ({max(Lx, Ly):.2f} mm vs {min(Lx, Ly):.2f} mm)"
    if face == "side":
        # side marks run along the part's long horizontal dimension, on the long wall
        axis = "x" if (bb[3] - bb[0]) >= (bb[4] - bb[1]) else "y"
        axis_why = f"long side wall ({max(bb[3] - bb[0], bb[4] - bb[1]):.2f} mm)"
    override = None
    if along in ("x", "y"):
        if along != axis:
            override = along
            warns.append(f"--along {along} puts the text across the {'short' if face == 'top' else 'other'} side "
                         f"({(Ly if along == 'y' else Lx) if face == 'top' else 0:.2f} mm instead of {max(Lx, Ly) if face == 'top' else 0:.2f} mm); "
                         "only do this to keep a polarity stripe or printed band clear")
        axis = along
    a = [1.0, 0.0, 0.0] if axis == "x" else [0.0, 1.0, 0.0]
    # ---- reading direction from pin 1 ----
    if p1:
        px, py = p1["x"] - cx, p1["y"] - cy
        along_p = px * a[0] + py * a[1]
        perp = [-a[1], a[0]]
        across_p = px * perp[0] + py * perp[1]
        if abs(along_p) > 1e-3:
            u = [-a[0], -a[1], 0.0] if along_p > 0 else a[:]
        else:                                   # pin 1 sits mid-length: put it at the bottom instead
            u = a[:] if across_p < 0 else [-a[0], -a[1], 0.0]
        pin1_src = "footprint pad " + p1["num"]
    else:
        u = a[:]
        pin1_src = "none (no footprint: assumed pad 1 at the -X / -Y end; pass --footprint)"
        warns.append("no footprint given: the reading direction assumes pin 1 at the negative end of the long axis; pass --footprint <part.kicad_mod>")
    v = [-u[1], u[0], 0.0]                      # text up = Z x u
    if p1:
        pu = (p1["x"] - cx) * u[0] + (p1["y"] - cy) * u[1]
        pv = (p1["x"] - cx) * v[0] + (p1["y"] - cy) * v[1]
        tol = 0.05 * max(bb[3] - bb[0], bb[4] - bb[1])
        corner = ("left" if pu < -tol else "right" if pu > tol else "centre") + "/" + ("lower" if pv < -tol else "upper" if pv > tol else "middle")
        if pv > tol and face == "top":
            warns.append(f"pin 1 lands UPPER-left in the standard pose ({corner}): the pads are numbered clockwise; check the footprint is not mirrored and the model matches it")
    else:
        corner = "unknown"
    out = dict(schema=SCHEMA, mpn=mpn, step=os.path.abspath(step_path), footprint=os.path.abspath(footprint) if footprint else None,
               standUp=[dict(axis=ax, quarterTurns=qq) for ax, qq in turns], standUpSource=up_src,
               face=face, pin1=dict(source=pin1_src, xyMm=[round(p1["x"], 4), round(p1["y"], 4)] if p1 else None, corner=corner),
               existingMarks=marks, vendorMarking=None)
    if face == "top":
        band = band_top
        L = (x1 - x0) if axis == "x" else (y1 - y0); Hh = (y1 - y0) if axis == "x" else (x1 - x0)
        out["face"] = "top"
        out["faceNormal"] = [0, 0, 1]
        side_turn = None
    else:
        front = [-v[0], -v[1], 0.0]
        key = {(0, -1): ("x", 3), (0, 1): ("x", 1), (-1, 0): ("y", 1), (1, 0): ("y", 3)}[(int(round(front[0])), int(round(front[1])))]
        st2 = ST.Step(s.text); st2._flat = True
        st2.rotate90(*key)
        meta_s = SVC.step_meta(st2.text, "mark-plan-meta-side")
        mark_ids_s = [p["id"] for p in meta_s.get("products", []) if p["name"] in MARK_PRODUCTS]
        up_t = _rot([0, 0, 1], *key)
        band = side_band(meta_s, mark_ids_s, up_t, side_pick)
        u_t = _rot(u, *key)
        bx0, by0, bx1, by1 = band["region"]
        L = (bx1 - bx0) if abs(u_t[0]) > 0.5 else (by1 - by0); Hh = (by1 - by0) if abs(u_t[0]) > 0.5 else (bx1 - bx0)
        out["faceNormal"] = front
        side_turn = dict(axis=key[0], quarterTurns=key[1], textUpInTurnedFrame=up_t, baselineInTurnedFrame=u_t)
        warns_led = "LED: the top window stays clear; the mark goes on the front long wall"
        hints.append(warns_led)
    lp, alt = lines_plan(mpn or "X" * 10, L, Hh, mw, mh)
    scale = iso_scale_px(meta)
    cap = lp["smallestLineMm"]
    cap_px = iso_cap_px(cap, u, scale, face)
    q_pose = 0 if angle_deg(u) in (90, 180) else 2
    u_pose = _rot(u, "z", q_pose)
    out.update(
        longAxis=dict(axis=axis, vector=a, why=axis_why, override=override),
        readingDirection=dict(vector=u, angleDeg=angle_deg(u), textUp=v,
                              rule="pin 1 / pad 1 at the start (left) of the text, and below it when it sits on a side"),
        band=dict(regionMm=[round(t, 4) for t in band["region"]], zMm=round(band["z"], 4), lengthMm=round(L, 4), heightMm=round(Hh, 4),
                  why=band["why"], terminationsExcludedBy=band["terminations"], trimmedEnds=band["trimmed"], keepouts=band["keepouts"],
                  sideTurn=side_turn, margins=dict(lengthEachSide=mw, heightEachSide=mh)),
        text=dict(lines=lp["lines"], chars=len(mpn or ""), capHeightMm=lp["capHeightMm"], smallestLineMm=lp["smallestLineMm"],
                  heightPctOfBand=round(100 * (lp["capHeightMm"] * (1 + (GAP + 1 if len(lp["lines"]) == 2 else 0))) / max(Hh, 1e-9), 1),
                  why=lp["why"], alternative=alt, font="service-step2glb /laser-etch (single-stroke outline font)"),
        etch=dict(poseQuarterTurnsZ=quarter_turns_to(u_t if face == "side" else u, (1, 0)) if True else 0, service="/laser-etch?mode=flat",
                  note="turn so the reading direction is +X, etch, fit the mark into the band, turn back"),
        outline=dict(upAxis="z", poseRotZDeg=90 * q_pose, rot=angle_deg(u_pose),
                     recipe=(f"adom-aiflow-etch outline <part.step> --footprint <part.kicad_mod> --mpn {mpn} (stands the STEP up ({up_src}), turns it "
                             f"{90 * q_pose} deg about Z and draws " + " / ".join(lp["lines"]) + f" on the planned band, rot {angle_deg(u_pose)})"),
                     why="iso camera looks from +X+Y+Z: text along +Y (rot 90) or -X (rot 180) reads left to right with pin 1 toward the viewer"),
        widget=dict(px=WIDGET_PX, pxPerMm=round(scale, 2), capHeightPx=round(cap_px, 1)),
        hints=hints, warnings=warns)
    out["vendorMarking"] = dict(present=vendor_mark, glyphFaces=band_top["vendorGlyphFaces"])
    out["summary"] = (f"Mark along the {L:.2f} mm axis ({'+' if u[0] + u[1] > 0 else '-'}{axis.upper()}), {lp['capHeightMm']:.2f} mm text"
                      f"{' (2 lines)' if len(lp['lines']) == 2 else ''}, upright with pin 1 {corner.replace('/', ' ')}, "
                      f"outline rot {out['outline']['rot']} after a {out['outline']['poseRotZDeg']} deg pose turn; "
                      f"~{cap_px:.0f} px caps at {WIDGET_PX} px")
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("step"); ap.add_argument("--footprint"); ap.add_argument("--mpn"); 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=["x", "y"]); ap.add_argument("--json")
    ap.add_argument("--terminations", default="geometry", choices=["geometry", "pads"], help="how to keep the text off metal end caps: from the model's faces (default) or between the footprint pads")
    a = ap.parse_args()
    fp = a.footprint
    if not fp:
        c = [os.path.join(os.path.dirname(a.step) or ".", f) for f in os.listdir(os.path.dirname(a.step) or ".") if f.endswith(".kicad_mod")]
        if len(c) == 1:
            fp = c[0]
    p = plan(a.step, fp, a.mpn or os.path.splitext(os.path.basename(a.step))[0].upper(), a.face, a.up, a.side_band, along=a.along, terminations=a.terminations)
    s = json.dumps(p, indent=1)
    if a.json:
        open(a.json, "w").write(s + "\n")
    print(s)
    print("Hint: " + p["summary"], file=sys.stderr)
    for w in p["warnings"]:
        print("Hint: WARNING " + w, file=sys.stderr)


if __name__ == "__main__":
    main()