app
Adom Chip Thumbnailer
Public Made by Adomby adom
The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app + CLI.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
#!/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()