main
John Lauer Publish 0.2.19 81ffca7
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
#!/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


TOP_TOL = 2e-3


def _planar_up(f, top_z=None, tol=0.99):
    """A flat face lying in a horizontal plane that faces up. 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.
    Horizontal is |n_z| > tol: KiCad's stock SOIC / TSSOP models store the top face reversed (normal
    0,0,-1), so the stored sign cannot decide up from down. A reversed face counts as facing up only
    when it lies at the top of the part's bbox (top_z): nothing can sit above it there. A downward
    face anywhere else (an underside, the bottom face) is never a marking face."""
    b = f["bbox_mm"]
    if b[5] - b[2] >= TOP_TOL or (b[3] - b[0]) < 1e-3 or (b[4] - b[1]) < 1e-3:
        return False
    n = f.get("normal")
    if n is None or n[2] > tol:
        return True
    return n[2] < -tol and top_z is not None and b[5] >= top_z - TOP_TOL


def _reversed_up(f, top_z):
    n = f.get("normal")
    return bool(n is not None and n[2] < -0.99 and _planar_up(f, top_z))


def pin_rows(pads, tol=0.02):
    """Two parallel rows of pins (SOIC, TSSOP, SOT-23-5, DIP...): the axis the rows run along, or None.
    Pads whose number repeats (exposed-pad sub-pads, thermal vias) and unnumbered pads are left out;
    at most two other pad numbers may remain, and only between the rows (an exposed pad)."""
    from collections import Counter
    cnt = Counter(p["num"] for p in pads)
    sig = [p for p in pads if p["num"] and cnt[p["num"]] == 1]
    if len(sig) < 4:
        return None
    for axis, key, run in (("y", "x", "y"), ("x", "y", "x")):
        groups = []
        for p in sorted(sig, key=lambda p: p[key]):
            if groups and abs(p[key] - groups[-1][-1][key]) <= tol:
                groups[-1].append(p)
            else:
                groups.append([p])
        rows = [g for g in groups if len(g) >= 2]
        if len(rows) != 2:
            continue
        rest = [p for p in sig if not any(p is q for g in rows for q in g)]
        lo, hi = sorted(g[0][key] for g in rows)
        if len(rest) > 2 or any(not lo < p[key] < hi for p in rest):
            continue
        return dict(axis=axis, pinsPerRow=[len(g) for g in rows], rowSpacingMm=round(hi - lo, 4), exposed=len(rest))
    return None


def _is_disc(f):
    """A round flat face (a pin-1 dot, an ejector-pin dimple floor): its bbox is square and it fills about
    pi/4 of it. A letter is not a lone disc ('O' has a hole, '.' alone is not a marking)."""
    b = f["bbox_mm"]; w, h = b[3] - b[0], b[4] - b[1]
    if w <= 0 or h <= 0 or abs(w - h) > 0.05 * max(w, h):
        return False
    return 0.74 <= f["area_mm2"] / (w * h) <= 0.80


def vendor_glyph_faces(faces, face, z, region):
    """Faces that look like printed text on the top face (issue #89): flat faces coplanar with the top (or a
    raised letter's own flat top just above it), text-sized (under 0.5 % of the top face), lying wholly inside
    the top face's interior. Not counted: fillet and chamfer strips and corner blends along the boundary (not
    flat, or reaching the edge), the walls of a pin-1 dimple (cylinders, cones), a round dot (pin-1 mark,
    ejector pin), and the same outline listed again (under the assembly product, or a raised letter's floor)."""
    x0, y0, x1, y1 = region
    fa = face["area_mm2"]
    edge = max(0.02, 0.01 * min(x1 - x0, y1 - y0))
    out, seen = [], set()
    for f in faces:
        if f is face or f["area_mm2"] >= 0.005 * fa:
            continue
        b = f["bbox_mm"]
        if b[5] - b[2] >= TOP_TOL or abs(b[5] - z) > 0.05:
            continue                                   # not flat, or not on the top face (raised or sunk letters within 0.05 mm)
        if f.get("surface_type") not in (None, "PLANE", "BSPLINE", "BEZIER"):
            continue
        if not (b[0] >= x0 + edge and b[3] <= x1 - edge and b[1] >= y0 + edge and b[4] <= y1 - edge):
            continue                                   # touches the boundary: a strip or a corner, not an island
        if _is_disc(f):
            continue
        key = (tuple(round(b[i], 3) for i in (0, 1, 3, 4)), round(f["area_mm2"], 4))   # one letter's top and floor, or a repeat
        if key in seen:
            continue
        seen.add(key); out.append(f)
    return out


def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry", axis=None):
    """The usable band on the top face. `axis` ("x"/"y") fixes the mark axis in advance (pin rows): end
    trims and keep-outs then shorten the band along that axis and never swap it."""
    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, bb[5]) 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"
        # a raised vendor marking puts a letter's own top highest over the centre: the top face is the large flat
        # face just under it (issue #89), and the letter is then counted as a glyph on it
        foot = (bb[3] - bb[0]) * (bb[4] - bb[1])
        under = [f for f in hit if f["area_mm2"] >= 10 * face["area_mm2"] and face["bbox_mm"][5] - 0.1 <= f["bbox_mm"][5] < face["bbox_mm"][5]]
        if face["area_mm2"] < 0.05 * foot and under:
            face = max(under, key=lambda f: (f["area_mm2"], round(f["bbox_mm"][5], 4)))
            why = "largest flat face over the body centre, under a raised marking"
    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 = (axis == "x") if axis in ("x", "y") else (x1 - x0) >= (y1 - y0)
    reversed_top = _reversed_up(face, bb[5])
    trims, keepouts = [], []
    # terminations / raised features at the ends: faces at or above the band, overlapping it at an end
    fa = face["area_mm2"]
    # small faces at the top's height (fillet strips, corner blends, dots, letters): never trims or keep-outs.
    # Which of them look like printed text is vendor_glyph_faces' question, below.
    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))
    # the vendor-marking test reads the whole top face, before any trim (issue #89)
    vendor = vendor_glyph_faces(faces, face, z, face["bbox_mm"][0:2] + face["bbox_mm"][3:5])
    # 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(vendor), reversedNormal=reversed_top,
                vendorGlyphFaceIds=[f["face_id"] for f in vendor],
                vendorGlyphFaceList=[f'{f["face_id"]} ({f.get("surface_type") or "?"}, {f["area_mm2"]:.3f} mm2 at '
                                     f'{(f["bbox_mm"][0] + f["bbox_mm"][3]) / 2:.2f},{(f["bbox_mm"][1] + f["bbox_mm"][4]) / 2:.2f})' for f in vendor])


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, top) and top - f["bbox_mm"][5] < TOP_TOL 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 = [], []
    face_arg = face
    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)")
    rows = None if two_terminal else pin_rows(pads)
    # ---- 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,
                        axis=rows["axis"] if rows and face == "top" else None)
    if band_top.get("reversedNormal"):
        hints.append("the top face is stored with a reversed normal (0,0,-1), as in KiCad's stock SOIC / TSSOP models; it is the top face by position (top of the part's bbox)")
    x0, y0, x1, y1 = band_top["region"]
    vendor_mark = band_top["vendorGlyphFaces"] >= 8
    if vendor_mark:
        named = ", ".join(band_top["vendorGlyphFaceList"][:12]) + (" ..." if band_top["vendorGlyphFaces"] > 12 else "")
        warns.append(f"the vendor model already carries a printed marking ({band_top['vendorGlyphFaces']} glyph faces on the top: {named}): 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)"
        axis_src = "two-terminal"
        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 rows:
        # two rows of pins: the mark runs parallel to the rows; a pin-1 dimple only shortens the band
        axis = rows["axis"]
        axis_why = (f"two pin rows ({' + '.join(str(n) for n in rows['pinsPerRow'])} pins, {rows['rowSpacingMm']:.2f} mm apart): "
                    f"parallel to the pin rows (band {Lx if axis == 'x' else Ly:.2f} mm along, {Ly if axis == 'x' else Lx:.2f} mm across)")
        axis_src = "pin-rows"
    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"
        axis_src = "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)"
        axis_src = "longest"
    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)"
        axis_src = "side-wall"
    override = None
    if along in ("x", "y"):
        if along != axis:
            override = along
            if axis_src == "pin-rows" and face == "top":
                warns.append(f"--along {along} puts the text ACROSS the pin rows; ICs are marked parallel to the pin rows (the planner's {axis.upper()})")
            else:
                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,
               inputs=dict(up=up, face=face_arg, along=along,
                           upSupplied=up in ("z", "y"), faceSupplied=face_arg in ("top", "side"),
                           faceSource=(f"--face {face_arg}" if face_arg in ("top", "side") else
                                       "inferred: LED detected, front long side wall" if face == "side" else "inferred: top face")),
               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, source=axis_src, override=override, pinRows=rows),
        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"], faces=band_top["vendorGlyphFaceList"])
    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()