← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-chip-thumbnailer"-version = "0.6.0"+version = "1.2.0" dependencies = [  "anyhow",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-chip-thumbnailer"-version = "0.6.0"+version = "1.2.0" edition = "2021" description = "Chip icon factory: renders the full 3D chip-icon family (orientations, transparent icons, name-lasered + vector outlines) from a STEP via service-step2glb OCCT. Live web app + CLI." license = "MIT"
README.md+18−29
@@ -22,36 +22,30 @@ stays a lean Rust client and does the alpha-keying locally. |---|---| | `<MPN>-3d-iso[-<axis>][-<size>].png` | shaded 3D render, 6 face-up orientations x sm/md/lg | | `<MPN>-3d-iso[-<axis>]-icon.png` | the same, alpha-keyed to a **transparent** background |-| `<MPN>-3d-iso-named[-icon].png` | part number **lasered** onto the chip top in the clean line-font (180, reads right-side-up) |-| `<MPN>-3d-iso-named90[-icon].png` | the name at a right angle (90), for chips taller than wide |+| `<MPN>-etched.step` | the model with its flat part-number mark (`Adom.Chip` + `Adom.LaserEtch`, colours kept) |+| `<MPN>-3d-iso-named[-icon].png` | the marked chip in the standard iso pose: pin 1 toward you, the part number upright |+| `<MPN>.mark-plan.json` | where the mark goes and why (face, long axis, band, size, reading direction) | | `<MPN>-3d-outline-<style>[-named].svg` | **11 vector outline styles** via OCCT hidden-line removal | | `<MPN>-thumbs.json` | self-describing manifest (palette, files, full-fidelity tool) |  ## The part number, lasered onto the chip (no other EDA tool does this) -chip-thumbnailer **draws the manufacturer part number onto the chip's top-face** in the same crisp single-stroke line-font the vector outlines use. It is-not a flat label pasted over the render: the text is projected onto the top-plane through the live 3D camera, so it lies flat on the chip and follows the-isometric perspective. A clean engraved/laser look, not a noisy 3D extrusion.-The text always **faces the viewer** (the rotation is chosen per up-axis, so it-reads forward rather than upside-down or backwards), and an optional **second-line** carries a variant/date marking. The same mark comes on the vector-outlines too.+chip-thumbnailer puts the manufacturer part number on the chip **the way a real part is marked**,+decided by Adom's mark planner rather than by hand: -![Clean single-stroke laser-mark of the part number on the chip, plus the name-marked vector outline](screenshots/name-laser.png)--## How the part number lands on the top surface+- **along the longest usable side** of the marking face: a resistor or capacitor along its body,+  between the metal end caps; an IC parallel to its pin rows; an LED on its front side wall, so the+  window stays clear;+- **as large as fits**, on one line, or two equal lines when that makes both larger;+- **upright and left to right** with pin 1 at the lower left, so the outline shows pin 1 toward you;+- **the same mark everywhere**: the etched STEP, the shaded icon and every named outline agree on face,+  direction and place. -Most parts have leads that splay past the body, a base flange, or bevels, so the-full bounding box is wider than the real top surface. chip-thumbnailer takes the-**top 10% slice** of the height and re-bounds just that geometry to get the true-top-face footprint, then insets the text 10% on every side. The mark lands on-the actual top surface on SOICs (leads excluded), QFNs, and domed/flanged-sensors alike. The dashboard shows this as a **placement-debug tile** (full-bounding box in red, top-slice box in cyan):+Every named outline passes a legibility check before it is written (direction, position, and text+height at a 320 px widget). One that fails is kept aside with the reason, never published as the named+outline. -![How placement is derived: full bbox in red, top-slice bbox in cyan, then the lasered result with a 2-line variant](screenshots/placement.png)+![Clean single-stroke laser-mark of the part number on the chip, plus the name-marked vector outline](screenshots/name-laser.png)  ## 11 outline styles @@ -69,16 +63,11 @@ Every outline style also comes **name-marked**, the chip's line-art silhouette with the manufacturer part number on it. This is exactly what a schematic symbol wants in its body: the real chip shape and the part number, instead of a featureless rectangle. adom-symbol drops one straight into the symbol body. The-name uses the **same top-slice placement and viewer-facing rotation as the 3D-laser**, so the outline and the shaded icon agree.--The mark comes two ways: the **facing** orientation (reads forward to the-camera) and a **right-angle** alternate, for a long part number on a narrow chip-that would otherwise spill:+name is the **same mark as the etched model**: same face, same direction, same place, drawn in+the standard pose with pin 1 toward you:  ![Name-marked outlines, facing orientation](screenshots/named-outlines.png) -![Name-marked outlines, right-angle alternate](screenshots/named90-outlines.png)  ## Works across every package type 
SKILL.md+34−13
@@ -1,6 +1,6 @@ --- name: chip-thumbnailer-description: "The chip-icon factory. From a single STEP file it renders the complete family of CHIP icons (the 3D body in every form): 6 face-up shaded orientations x sm/md/lg, transparent alpha-keyed icons, the part-number LASERED onto the chip top (180 reads upright + a 90 right-angle variant), the embossed .step that travels to KiCad, and 11 vector OUTLINE styles via OCCT hidden-line removal (colours white + teal, thicknesses hairline..heavy, approaches solid/dashed/neon/x-ray/blueprint/ink-on-paper). Ships a live web app (chip-thumbnailer serve|app) that fills each icon into a dashed box in real time with per-icon size/format/KB/transparency, plus a CLI (once/scan/status/rerender). The heavy OCCT work runs on service-step2glb; chip-thumbnailer is a lean Rust client + does alpha-keying locally. NOT symbol/footprint thumbnails (those are adom-symbol / adom-footprint). Triggers: chip icon, chip thumbnail, chip thumbs, render chip, chip 3d icon, transparent chip icon, name on chip, laser part number, chip outline, vector outline, chip-thumbnailer, .thumb-pending, chip-thumbs.json, chip wiki hero, library card, regenerate chip icons."+description: "The chip-icon factory. From a single STEP file it renders the complete family of CHIP icons (the 3D body in every form): 6 face-up shaded orientations x sm/md/lg, transparent alpha-keyed icons, the part number placed by the MARK PLANNER (along the long axis, upright with pin 1 toward the viewer, gated) as an etched STEP, a name-lasered iso and named outlines, and 11 vector OUTLINE styles via OCCT hidden-line removal (colours white + teal, thicknesses hairline..heavy, approaches solid/dashed/neon/x-ray/blueprint/ink-on-paper). Ships a live web app (chip-thumbnailer serve|app) that fills each icon into a dashed box in real time with per-icon size/format/KB/transparency, plus a CLI (once/scan/status/rerender). The heavy OCCT work runs on service-step2glb; chip-thumbnailer is a lean Rust client + does alpha-keying locally. NOT symbol/footprint thumbnails (those are adom-symbol / adom-footprint). Triggers: chip icon, chip thumbnail, chip thumbs, render chip, chip 3d icon, transparent chip icon, name on chip, laser part number, chip outline, vector outline, chip-thumbnailer, .thumb-pending, chip-thumbs.json, chip wiki hero, library card, regenerate chip icons." user-invocable: true --- @@ -20,10 +20,12 @@ wiki chip pages consume. ``` <MPN>-3d-iso[-<axis>][-<size>].png     shaded render, 6 orientations x sm/md/lg <MPN>-3d-iso[-<axis>]-icon.png         transparent (alpha-keyed) icon  <- adom-symbol reads this-<MPN>-3d-iso-named[-icon].png          part number lasered on top, 180 (reads upright)-<MPN>-3d-iso-named90[-icon].png        the name at a right angle, 90 (taller/wider fit)-<MPN>-named.step                       embossed STEP (the mark travels to KiCad)-<MPN>-3d-outline-<style>[-named].svg   11 OCCT-HLR vector outline styles, plain + name-marked+<MPN>-etched.step                      the model + its flat Adom.LaserEtch mark (adom-aiflow-etch)+<MPN>-etched.check.json|png            the legibility gate's verdict + top view in the standard pose+<MPN>-3d-iso-named[-icon].png          the etched model in the standard iso pose (the real mark)+<MPN>-3d-outline-<style>[-named].svg   11 OCCT-HLR outline styles, standard pose, plain + name = the mark+<MPN>-3d-outline-<style>-named.FAILED-GATE.svg   only when the gate failed: never use it, read the Hints+<MPN>.mark-plan.json                   the planner's JSON (schema adom/mark-plan@1) <MPN>-thumbs.json                      self-describing manifest (READ THIS first) ``` @@ -75,16 +77,35 @@ on OCCT; jobs run in isolated subprocesses, `202 + job_id`, poll |---|---| | `POST /thumbnail?upAxis=<a>` | one shaded orientation PNG | | `POST /outline?upAxis=<a>&name=&suffix=&rot=` | `{style: svg}` for the 11 styles (OCCT HLR) |-| `POST /name-emboss?upAxis=<a>&name=&rot=` | embossed STEP + rendered iso (base64) |--`rot` = text orientation: `180` (default, reads upright to the iso-camera), `90` (a right angle, the `named90` set, faces the viewer). The text is scaled to-the chip's top-face bounding box at whichever rotation.+| `POST /laser-etch?name=&line2=&mode=flat` | the mark (on the planner's band plate), grafted onto the model |+| `POST /step-meta` | faces, normals, bboxes: the planner's geometry |++The name is NOT a fixed `rot` any more (the old fixed 180 / 90 variants are how names ended up across+the short side or upside down; `once` deletes stale `named90` / `named180` files). The thumbnailer runs+the shared mark planner (adom-aiflow's `tools/laser-etch`, vendored in `planner/`, plain python3):+it stands the STEP up, turns it to the standard iso pose (pin 1 / pins 1 to 3 toward the viewer),+draws the name with the planner's lines on the planner's band (between end caps, clear of the pin-1+dot, on an LED's front wall) and gates every SVG. `rot` in `/outline` is the CCW angle of the text+baseline from +X; the iso camera looks from +X+Y+Z, so only rot 90 (+Y) and 180 (-X) read left to right.++## Marking checklist++1. The rule: the mark runs along the longest usable dimension of the marking face (chip R/C/L along+   the body between the end caps; ICs parallel to the pin rows; LEDs on the front long side wall,+   window clear), as large as fits (one line, two equal lines only when both are larger), upright and+   left to right with pin 1 / pad 1 lower left seen from the top, so the iso outline shows pin 1+   toward the viewer. Flat light mark, `Adom.Chip` / `Adom.LaserEtch`. The outline's name is the same+   mark: same face, direction and place.+2. Give it the footprint: `<MPN>.kicad_mod` beside the STEP (pin 1 and the model rotation come from it).+3. `once` prints `Hint:` lines on success too ("Mark along 2.17 mm axis, 0.26 mm text, upright with pin 1+   left lower: PASS"). A `WARNING ... FAILED the legibility gate` means the `-named.svg` was NOT written:+   read the Hints, fix the plan, rerun; never rename a FAILED-GATE file.+4. Look before you report: open `<MPN>-etched.check.png` and one `-named.svg` at 320 px.+5. Same tools by hand: `adom-aiflow-etch plan|check|outline` (adom/adom-aiflow).  chip-thumbnailer does the **transparent-icon alpha-key in pure Rust** (median the four shaded-render corners as the background, feather to alpha-by RGB distance, T0=16/T1=44), so there are no Python/numpy deps in the-client.+by RGB distance, T0=16/T1=44). The mark planner needs plain python3 (no packages).  ## How AI agents should use this @@ -101,7 +122,7 @@ When the user wants to show / preview a chip:   calls `chip-thumbnailer once` for the full family (it no longer shells   its own OCCT scripts). - **service-step2glb** owns the OCCT endpoints (`/thumbnail`, `/outline`,-  `/name-emboss`).+  `/laser-etch`, `/step-meta`). - **adom-symbol** consumes the transparent `-3d-iso-icon.png` + the   outline/named SVGs as its centre overlay (it does NOT make them). - **adom-step** is the full-fidelity 3D viewer to launch on click-through.
VERSION+1−1
@@ -1 +1 @@-0.5.1+1.2.0
bin/adom-chip-thumbnailer
⋯ 1 unchanged line ⋯
install.sh+10−2
@@ -1,14 +1,22 @@ #!/bin/bash # Download the latest linux release binary of adom-chip-thumbnailer from the # Adom Wiki and install it to ~/.local/bin. The binary ships as a release-# asset (not bundled in the package tarball).+# asset (not bundled in the package tarball). The mark planner (plain python3,+# vendored from adom-aiflow's tools/laser-etch) installs beside it. set -euo pipefail REF="adom/adom-chip-thumbnailer"+HERE="$(cd "$(dirname "$0")" && pwd)" BIN="${HOME}/.local/bin"; mkdir -p "$BIN" VER="$(adom-wiki pkg info "$REF" 2>/dev/null \   | python3 -c "import sys,json;print((json.load(sys.stdin).get('versions') or [{}])[0].get('version',''))" 2>/dev/null)"-[ -z "$VER" ] && VER="1.1.2"+[ -z "$VER" ] && VER="1.2.0" curl -fsSL "https://wiki.adom.inc/download/${REF}/${VER}/adom-chip-thumbnailer" \   -o "$BIN/adom-chip-thumbnailer" chmod +x "$BIN/adom-chip-thumbnailer"+SHARE="${HOME}/.local/share/adom-chip-thumbnailer"+if [ -d "$HERE/planner" ]; then+  mkdir -p "$SHARE"; rm -rf "$SHARE/planner"; cp -r "$HERE/planner" "$SHARE/planner"+  echo "Installed the mark planner -> $SHARE/planner (needs python3)"+fi+command -v python3 >/dev/null || echo "WARNING: python3 not found: named outlines need it (the mark planner)" echo "Installed adom-chip-thumbnailer ${VER} -> ${BIN}/adom-chip-thumbnailer"
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-chip-thumbnailer",-  "version": "1.1.10",+  "version": "1.2.0",   "type": "app",   "description": "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.",   "tags": [
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-chip-thumbnailer",-  "version": "1.1.10",+  "version": "1.2.0",   "type": "app",   "description": "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.",   "dependencies": {},
planner/SOURCE.jsonadded+16
@@ -0,0 +1,16 @@+{+ "source": "adom/adom-aiflow tools/laser-etch",+ "sourceVersion": "0.1.48",+ "note": "Vendored copy of the mark planner; do not edit here. Update in adom-aiflow and copy over; the sha256 list must match.",+ "sha256": {+  "mark_check.py": "2ed264eb9c4618fdfb579529bb1606102467141bacab627873ea689a99f4297c",+  "mark_etch.py": "2a2f56245d1ea20d288f0fddb17af8bc2ede2a0e99bbcb3d01b773839425a5db",+  "mark_geom.py": "18767a41c4bcd6742de5480051114e263a7ac2fd3d480530b02b7f36e3517b65",+  "mark_outline.py": "94cbd36b8ec488cc5309b49344deea9c23b656d4d09bb0c41b263a9414f29fb8",+  "mark_plan.py": "31594fafed47017f1e748d61c550a6edc925e6b0a0076d1663874a662b4b9504",+  "mark_svc.py": "5c9a7c74209fd1ce7c35cc1fddfdf2027f140dfe27fb466ace0b651ef649b7e1",+  "proxy_box.step": "4fda23eee6a720c3dd698f7d0ae0dbdab3d1d2e5740d7e6009449bf9dbbe7ccf",+  "step_text.py": "6a36d9dea6d45bb8c47706a4f17ab76d4db897cb140790f75d2f3d887d8edd74",+  "test_mark_gate.py": "096d9ca1560b6ac5dae909efe45f9d1004d9155cec046a1d977cdca127d6bb0f"+ }+}\ No newline at end of file
planner/mark_check.pyadded+363
@@ -0,0 +1,363 @@+#!/usr/bin/env python3+"""LEGIBILITY GATE for a laser-marked part: `adom-aiflow-etch check <etched.step> [--outline <svg>]+[--footprint <kicad_mod>] [--plan <x.mark-plan.json>]`. Runs automatically at the end of every etch.++Checks (FAIL blocks; WARN is printed as a Hint):+  axis      text baseline vs the planned long axis: parallel PASS; across the long axis FAIL (unless the+            band is near square, ratio < 1.15)+  upright   reading direction in the standard pose (pin 1 / pad 1 lower left): matches the plan PASS,+            reversed FAIL ("flip 180")+  size      smallest line's cap height vs what fits: >= 80 % PASS, 60-80 % WARN, < 60 % FAIL+  band      the mark lies inside the usable band (off the terminations, clear of the pin-1 dot): FAIL if not+  flat      the mark is flat and coplanar with its face (within 0.02 mm): FAIL if raised or buried+  group     Adom.LaserEtch next to Adom.Chip: WARN on the legacy 'laser_etch' name+  colour    a light mark (sRGB luminance >= 0.5): WARN when dark (only right on a light body)+  widget    cap height in pixels with the iso outline drawn 320 px wide: >= 6 px PASS, 4-6 WARN, < 4 FAIL+  outline   (with --outline) the outline's name runs the same direction as the model mark, sits on the+            same face and within 15 % of the same place, and its caps are >= 6 px at 320 px+Writes <etched>.check.json and a top view of the mark from the service, <etched>.check.png, with the+band (green), the reading arrow and the verdict drawn on it. Exit status 1 on FAIL."""+import argparse, io, 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+import mark_plan as MP+import mark_geom as MG++SIZE_PASS, SIZE_FAIL = 0.80, 0.60+PX_PASS, PX_FAIL = 6.0, 4.0+++def _polylines(svg):+    out = []+    for m in re.finditer(r"<polyline\b([^>]*)>", svg):+        at = m.group(1)+        pts = re.search(r'points="([^"]+)"', at)+        w = re.search(r'stroke-width="([^"]+)"', at); c = re.search(r'stroke="([^"]+)"', at)+        if pts:+            out.append(dict(pts=[tuple(map(float, p.split(","))) for p in pts.group(1).split()], w=float(w.group(1)) if w else 1.0, c=c.group(1) if c else "",+                            dash=("stroke-dasharray" in at), filt=("filter=" in at), attrs=re.sub(r'\s*points="[^"]*"', "", at)))+    return out+++def outline_text(svg):+    """Split an outline SVG into (name strokes, part strokes). adom-aiflow-etch outline wraps the name in+    <g id="adom-mark-name">; a service-drawn name is the trailing run of one stroke class that follows a+    different class, is no wider and has fewer strokes (multi-layer styles: hidden dashed / glow / core)."""+    m = re.search(r'<g id="adom-mark-name"[^>]*>(.*?)</g>', svg, re.S)+    if m:+        return (_polylines(m.group(1)) or None), _polylines(svg[:m.start()] + svg[m.end():])+    pl = _polylines(svg)+    if not pl:+        return None, pl+    cls = lambda t: (t["w"], t["c"], t.get("dash"), t.get("filt"))+    runs = []+    for t in pl:+        if runs and cls(runs[-1][0]) == cls(t):+            runs[-1].append(t)+        else:+            runs.append([t])+    if len(runs) >= 2:+        last, prev = runs[-1], runs[-2]+        if last[0]["w"] <= prev[0]["w"] and len(last) < len(prev) and not last[0].get("dash"):+            return last, [t for r in runs[:-1] for t in r]+    return None, pl+++_iso = MG.iso+++def check_outline(svg_path, plan, mark_c_posed, meta_posed, mark_box_posed=None, verts=None):+    svg = open(svg_path).read()+    vb = re.search(r'viewBox="([^"]+)"', svg)+    W = float(vb.group(1).split()[2]) if vb else 320.0+    txt, body = outline_text(svg)+    res = dict(file=svg_path)+    if not txt:+        # drawn from the etched STEP: the mark's own glyph edges are in the outline, so it IS the mark;+        # confirm strokes sit where the mark projects+        pl = _polylines(svg)+        allp = [p for t in pl for p in t["pts"]]+        if mark_box_posed and allp:+            corners = []+            for f in meta_posed["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]):+                            corners.append(_iso((x, y, z)))+            bx0 = min(p[0] for p in allp); bx1 = max(p[0] for p in allp); by0 = min(p[1] for p in allp); by1 = max(p[1] for p in allp)+            mx0 = min(p[0] for p in corners); mx1 = max(p[0] for p in corners); my0 = min(p[1] for p in corners); my1 = max(p[1] for p in corners)+            sc = ((bx1 - bx0) / (mx1 - mx0) + (by1 - by0) / (my1 - my0)) / 2+            mb = mark_box_posed+            pc = [_iso((x, y, z)) for x in (mb[0], mb[3]) for y in (mb[1], mb[4]) for z in (mb[2], mb[5])]+            qx0 = bx0 + (min(p[0] for p in pc) - mx0) * sc; qx1 = bx0 + (max(p[0] for p in pc) - mx0) * sc+            qy0 = by0 + (min(p[1] for p in pc) - my0) * sc; qy1 = by0 + (max(p[1] for p in pc) - my0) * sc+            inside = [t for t in pl if all(qx0 - 2 <= p[0] <= qx1 + 2 and qy0 - 2 <= p[1] <= qy1 + 2 for p in t["pts"])]+            if len(inside) >= 6:+                res.update(status="PASS", hint=f"outline drawn from the etched STEP: {len(inside)} mark strokes on the marked face, the name is the mark itself (same face, direction and place by construction)")+                return res+        res.update(status="WARN", hint="no name in this outline (no separate name strokes and no mark edges where the mark is); nothing to compare")+        return res+    pts = [p for t in txt for p in t["pts"]]+    bpts = [p for t in body for p in t["pts"]]+    # direction: principal axis of the name strokes; sign: first stroke -> last stroke+    mx = sum(p[0] for p in pts) / len(pts); my = sum(p[1] for p in pts) / len(pts)+    sxx = sum((p[0] - mx) ** 2 for p in pts); syy = sum((p[1] - my) ** 2 for p in pts); sxy = sum((p[0] - mx) * (p[1] - my) for p in pts)+    ang = 0.5 * math.atan2(2 * sxy, sxx - syy)+    d = (math.cos(ang), math.sin(ang))+    f0 = txt[0]["pts"]; f1 = txt[-1]["pts"]+    c0 = (sum(p[0] for p in f0) / len(f0), sum(p[1] for p in f0) / len(f0)); c1 = (sum(p[0] for p in f1) / len(f1), sum(p[1] for p in f1) / len(f1))+    lines2 = len(plan["text"]["lines"]) == 2+    if lines2:     # the first stroke starts line 1 at the left; the last ends line 2 at the right: still left -> right overall+        pass+    if (c1[0] - c0[0]) * d[0] + (c1[1] - c0[1]) * d[1] < 0:+        d = (-d[0], -d[1])+    # expected on-screen reading direction: the planned baseline in the outline's pose+    qp = plan["outline"]["poseRotZDeg"] // 90+    u = MP._rot(plan["readingDirection"]["vector"], "z", qp)+    a0 = _iso((0, 0, 0)); a1 = _iso(u)+    e = (a1[0] - a0[0], a1[1] - a0[1]); n = math.hypot(*e); e = (e[0] / n, e[1] / n)+    cosang = d[0] * e[0] + d[1] * e[1]+    angle = math.degrees(math.acos(max(-1, min(1, abs(cosang)))))+    # position: calibrate the drawing against the model's vertices (exact for an orthographic view);+    # without them, fit the projected face boxes to the drawn extents+    if verts:+        cal = MG.calibrate(body, verts)+        ex, ey = cal["o"][0] + cal["s"] * _iso(mark_c_posed)[0], cal["o"][1] + cal["s"] * _iso(mark_c_posed)[1]+        bx0 = min(p[0] for p in bpts); bx1 = max(p[0] for p in bpts); by0 = min(p[1] for p in bpts); by1 = max(p[1] for p in bpts)+    corners = []+    for f in meta_posed["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]):+                    corners.append(_iso((x, y, z)))+    bx0 = min(p[0] for p in bpts); bx1 = max(p[0] for p in bpts); by0 = min(p[1] for p in bpts); by1 = max(p[1] for p in bpts)+    mx0 = min(p[0] for p in corners); mx1 = max(p[0] for p in corners); my0 = min(p[1] for p in corners); my1 = max(p[1] for p in corners)+    s = ((bx1 - bx0) / (mx1 - mx0) + (by1 - by0) / (my1 - my0)) / 2+    if not verts:+        exp = _iso(mark_c_posed)+        ex = bx0 + (exp[0] - mx0) * s; ey = by0 + (exp[1] - my0) * s+    tx = (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2; ty = (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2+    dist = math.hypot(tx - ex, ty - ey) / max(bx1 - bx0, by1 - by0)+    size_ratio = None+    if mark_box_posed and verts:+        mb = mark_box_posed+        pc = [_iso((x, y, z)) for x in (mb[0], mb[3]) for y in (mb[1], mb[4]) for z in (mb[2], mb[5])]+        along_m = max(p[0] * d[0] + p[1] * d[1] for p in pc) - min(p[0] * d[0] + p[1] * d[1] for p in pc)+        along_t = max(p[0] * d[0] + p[1] * d[1] for p in pts) - min(p[0] * d[0] + p[1] * d[1] for p in pts)+        size_ratio = along_t / max(along_m * cal["s"], 1e-9)+    # cap height on screen: extent of the strokes across the reading direction, per line+    perp = (-d[1], d[0])+    across = [p[0] * perp[0] + p[1] * perp[1] for p in pts]+    span = max(across) - min(across)+    cap_px = (span / (2 + MP.GAP) if lines2 else span) * 320.0 / W+    ok_dir = cosang > 0.85+    reversed_ = cosang < -0.85+    st = "PASS"+    hints = []+    if not ok_dir:+        st = "FAIL"+        hints.append("outline name runs " + ("the opposite way (upside down)" if reversed_ else (f"across the long axis ({angle:.0f} deg off on screen)" if angle > 45 else f"{angle:.0f} deg off")) ++                     f" from the model's mark; regenerate it with the planner's recipe: {plan['outline']['recipe']}")+    if dist > 0.15:+        st = "FAIL"+        hints.append(f"outline name sits {100 * dist:.0f}% of the part size away from where the model's mark is; draw it on the same face (the planner's band)")+    if cap_px < PX_FAIL:+        st = "FAIL"; hints.append(f"outline name caps are {cap_px:.1f} px at 320 px: unreadable in the widget")+    elif cap_px < PX_PASS and st == "PASS":+        st = "WARN"; hints.append(f"outline name caps are {cap_px:.1f} px at 320 px: small in the widget")+    if size_ratio is not None and not (0.7 <= size_ratio <= 1.4):+        if st == "PASS":+            st = "WARN"+        hints.append(f"outline name is {size_ratio:.2f}x the etched mark's length; draw it with the planner's band (adom-aiflow-etch outline)")+    res.update(status=st, angleDeg=round(angle, 1), sameDirection=bool(ok_dir), offsetPct=round(100 * dist, 1), capHeightPx320=round(cap_px, 1),+               lengthVsMark=round(size_ratio, 2) if size_ratio else None, hints=hints)+    return res+++def render_top(step_text, a, plan, band, status, path, frame_label):+    try:+        png, hdr = SVC.thumbnail(step_text, "top", 640, 640, job="mark-check-top")+    except Exception as ex:  # the view is evidence, the verdict does not depend on it+        return None, f"top view not rendered: {ex}"+    try:+        from PIL import Image, ImageDraw+    except ImportError:+        open(path, "wb").write(png); return path, None+    im = Image.open(io.BytesIO(png)).convert("RGB")+    bb = json.loads(hdr.get("X-Bbox-Mm") or hdr.get("x-bbox-mm"))+    dx, dy = bb[0][1] - bb[0][0], bb[1][1] - bb[1][0]+    sc = 0.95 * 640 / max(dx, dy); cx, cy = (bb[0][0] + bb[0][1]) / 2, (bb[1][0] + bb[1][1]) / 2+    P = lambda x, y: (320 + (x - cx) * sc, 320 - (y - cy) * sc)+    d = ImageDraw.Draw(im)+    x0, y0, x1, y1 = band+    d.rectangle([P(x0, y1), P(x1, y0)], outline=(60, 200, 90), width=2)+    if a:+        c = a["centre"]; u = a["u"]; L = (a["bbox"][3] - a["bbox"][0]) if a["axis"] == "x" else (a["bbox"][4] - a["bbox"][1])+        s0 = P(c[0] - u[0] * L * 0.55, c[1] - u[1] * L * 0.55); s1 = P(c[0] + u[0] * L * 0.55, c[1] + u[1] * L * 0.55)+        d.line([s0, s1], fill=(255, 200, 40), width=3)+        hx, hy = s1[0] - s0[0], s1[1] - s0[1]; n = math.hypot(hx, hy) or 1; hx, hy = hx / n * 14, hy / n * 14+        d.polygon([s1, (s1[0] - hx - hy * 0.5, s1[1] - hy + hx * 0.5), (s1[0] - hx + hy * 0.5, s1[1] - hy - hx * 0.5)], fill=(255, 200, 40))+    col = {"PASS": (60, 200, 90), "WARN": (240, 180, 40), "FAIL": (235, 70, 60)}[status]+    d.rectangle([0, 0, 640, 26], fill=(13, 17, 23)); d.text((8, 7), f"{status}  {plan['mpn']}  top view ({frame_label})  green: usable band  arrow: reading direction", fill=col)+    im.save(path)+    return path, None+++def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_path=None, render=True, quiet=False):+    if plan is None and plan_path:+        j = json.load(open(plan_path)); plan = j.get("plan", j)+    if plan is None:+        side = os.path.splitext(step_path)[0] + ".mark-plan.json"+        if os.path.exists(side):+            j = json.load(open(side)); plan = j.get("plan", j)+    s = ST.Step(step_path)+    name = MG.mark_name(s)+    if plan is None:+        if not mpn and name:             # unknown text: plan for as many characters as the mark has glyphs+            s0 = ST.Step(step_path); s0.flatten(); a0 = MG.analyse(s0, name)+            mpn = "X" * (len(a0["faces"]) if a0 else 10)+        plan = MP.plan(step_path, footprint, mpn or os.path.splitext(os.path.basename(step_path))[0].upper())+    if plan["longAxis"].get("override") and os.path.exists(plan.get("step") or ""):+        # an --along override is judged against the planner's own default, never against itself+        plan = MP.plan(plan["step"], plan.get("footprint"), plan.get("mpn"), plan["face"])+    s.flatten()+    turns = [(t["axis"], t["quarterTurns"]) for t in plan["standUp"]]+    if plan["band"]["sideTurn"]:+        turns.append((plan["band"]["sideTurn"]["axis"], plan["band"]["sideTurn"]["quarterTurns"]))+    for ax, q in turns:+        s.rotate90(ax, q)+    checks, hints = [], []+    def add(key, status, msg, **kw):+        checks.append(dict(check=key, status=status, detail=msg, **kw))+    band = plan["band"]["regionMm"]; bz = plan["band"]["zMm"]+    L, Hh = plan["band"]["lengthMm"], plan["band"]["heightMm"]+    u_plan = plan["readingDirection"]["vector"]+    if plan["band"]["sideTurn"]:+        u_plan = plan["band"]["sideTurn"]["baselineInTurnedFrame"]+    a = MG.analyse(s, name) if name else None+    if not a:+        add("mark", "FAIL", "no laser mark in this STEP (no Adom.LaserEtch / laser_etch group)")+    else:+        # axis+        ua = a["u"]+        dot = ua[0] * u_plan[0] + ua[1] * u_plan[1]+        long_mm, short_mm = max(L, Hh), min(L, Hh)+        aspect = long_mm / max(short_mm, 1e-9)+        if abs(dot) < 0.5:+            sev = "FAIL" if aspect >= 1.15 or plan["longAxis"].get("why", "").startswith("two-terminal") else "WARN"+            add("axis", sev, f"Text runs across the {(Hh if True else L):.2f} mm side; rerun with the planner default (along {L:.2f} mm).", angleDeg=90)+        else:+            add("axis", "PASS", f"text along the {L:.2f} mm axis", angleDeg=0)+            if dot < 0:+                add("upright", "FAIL", "Upside down in the standard pose; flip 180 (rerun adom-aiflow-etch without --along so the planner reads it from pin 1).")+            else:+                add("upright", "PASS", f"upright, left to right, pin 1 {plan['pin1']['corner'].replace('/', ' ')}")+        # size against what fits, with the mark's own line count+        nlines = len(a["lines"])+        txt = "".join(plan["text"]["lines"]) if plan["text"]["lines"] else (mpn or "")+        if abs(dot) >= 0.5:+            fit_one = MP.text_fit(len(txt), L, Hh)+            best = max(plan["text"]["smallestLineMm"], fit_one)+            if nlines == 2 and len(plan["text"]["lines"]) == 2:+                best = plan["text"]["smallestLineMm"]+        else:+            best = plan["text"]["smallestLineMm"]+        cap = a["capMm"]+        ratio = cap / max(best, 1e-9)+        pct_band = 100 * cap * (nlines + (nlines - 1) * MP.GAP) / max(Hh, 1e-9)+        msg = f"text height {cap:.3f} mm, {100 * ratio:.0f}% of what fits ({best:.3f} mm), block fills {pct_band:.0f}% of the {Hh:.2f} mm band height"+        if ratio >= SIZE_PASS:+            add("size", "PASS", msg, capMm=round(cap, 4), fitMm=round(best, 4))+        else:+            add("size", "FAIL" if ratio < SIZE_FAIL else "WARN", f"Text height {cap:.2f} mm is {100 * ratio:.0f}% of what fits; increase to ~{best:.2f} mm (rerun adom-aiflow-etch with the planner's defaults).",+                capMm=round(cap, 4), fitMm=round(best, 4))+        # band and plane+        tol = 0.01+        mb = a["bbox"]+        # control points of glyph curves can sit a hair outside the drawn outline: allow 3 % of the cap+        tol += 0.03 * cap+        inside = mb[0] >= band[0] - tol and mb[1] >= band[1] - tol and mb[3] <= band[2] + tol and mb[4] <= band[3] + tol+        add("band", "PASS" if inside else "FAIL", "inside the usable band" + (" (between the terminations)" if plan["band"].get("trimmedEnds") or "two-terminal" in plan["longAxis"]["why"] else "") if inside else+            f"the mark leaves the usable band {['%.2f' % v for v in band]} mm (onto the terminations / pin-1 dot / edge); rerun with the planner defaults")+        dz = a["z"] - bz+        flat_ok = a["flatMm"] < 2e-3 and -0.002 <= dz <= 0.02+        add("flat", "PASS" if flat_ok else "FAIL", f"flat, coplanar with the face ({1000 * dz:+.0f} um)" if flat_ok else+            f"mark is {'not flat (%.3f mm thick)' % a['flatMm'] if a['flatMm'] >= 2e-3 else ('%.3f mm above' % dz if dz > 0 else '%.3f mm below' % -dz)} the face; marks are flat light faces on the surface, never raised or recessed lettering")+        add("group", "PASS" if name == "Adom.LaserEtch" else "WARN", "Adom.LaserEtch group" if name == "Adom.LaserEtch" else "legacy 'laser_etch' group; re-etch with adom-aiflow-etch for Adom.Chip / Adom.LaserEtch")+        rgb = MG.colour(s, name)+        if rgb:+            lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]+            add("colour", "PASS" if lum >= 0.5 else "WARN", f"light mark (sRGB {', '.join('%.2f' % c for c in rgb)})" if lum >= 0.5 else+                "dark mark: the rule is a flat white (light) mark; keep a dark one only on a light body (aluminium can, white LED housing) and say so", rgb=rgb)+        # widget readability (iso outline at 320 px)+        u_iso = plan["readingDirection"]["vector"]+        cap_px = MP.iso_cap_px(cap, u_iso, plan["widget"]["pxPerMm"], plan["face"])+        st = "PASS" if cap_px >= PX_PASS else "WARN" if cap_px >= PX_FAIL else "FAIL"+        add("widget", st, f"{cap_px:.1f} px caps with the outline 320 px wide" + ("" if st == "PASS" else ": too small to read in the widget; use two lines or a shorter mark (the user must agree)"), capHeightPx320=round(cap_px, 1))+    if outline:+        sp = ST.Step(step_path); sp.flatten()+        for t in plan["standUp"]:+            sp.rotate90(t["axis"], t["quarterTurns"])+        qp = plan["outline"]["poseRotZDeg"] // 90+        sp.rotate90("z", qp)+        meta_p = SVC.step_meta(sp.text, "mark-check-outline-meta")+        b = plan["band"]["regionMm"]+        c = [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2, plan["band"]["zMm"]]+        ap_ = MG.analyse(sp) if name else None+        if plan["band"]["sideTurn"]:+            c = ap_["centre"] if ap_ else c+        else:+            c = MP._rot(c, "z", qp)+        o = check_outline(outline, plan, c, meta_p, ap_["bbox"] if ap_ else None, verts=MG.vertices(sp))+        add("outline", o["status"], "; ".join(o.get("hints", [])) or (o.get("hint") or f"outline name: same direction, {o.get('offsetPct')}% offset, {o.get('capHeightPx320')} px caps"), **{k: v for k, v in o.items() if k not in ("status", "hints", "hint")})+    worst = "FAIL" if any(c["status"] == "FAIL" for c in checks) else "WARN" if any(c["status"] == "WARN" for c in checks) else "PASS"+    base = os.path.splitext(step_path)[0]+    rep = dict(schema="adom/mark-check@1", step=os.path.abspath(step_path), mpn=plan.get("mpn"), status=worst, checks=checks,+               plan=dict(summary=plan["summary"], band=plan["band"]["regionMm"], readingDirection=plan["readingDirection"]["vector"], lines=plan["text"]["lines"]),+               thresholds=dict(sizePass=SIZE_PASS, sizeFail=SIZE_FAIL, widgetPxPass=PX_PASS, widgetPxFail=PX_FAIL, outlineOffsetMax=0.15, coplanarMaxMm=0.02))+    if render:+        # show it in the standard pose: the planned reading direction to the right (pin 1 lower left)+        q = MP.quarter_turns_to(u_plan, (1, 0))+        sv = ST.Step(s.text); sv._flat = True; sv.rotate90("z", q)+        av = MG.analyse(sv, name) if a else None+        pts = [MP._rot([x, y, 0], "z", q) for x, y in ((band[0], band[1]), (band[2], band[3]))]+        bandv = [min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)]+        img, err = render_top(sv.text, av, plan, bandv, worst, base + ".check.png", "front side wall, standard pose" if plan["band"]["sideTurn"] else "standard pose: pin 1 lower left")+        rep["image"] = img+        if err:+            rep["imageError"] = err+    rep["reportPath"] = base + ".check.json"+    json.dump(rep, open(rep["reportPath"], "w"), indent=1)+    if not quiet:+        cap = next((c for c in checks if c["check"] == "size"), {})+        if worst == "PASS" and a:+            print(f"Hint: Mark along {plan['band']['lengthMm']:.2f} mm axis, {a['capMm']:.2f} mm text, upright with pin 1 {plan['pin1']['corner'].replace('/', ' ')}: PASS", file=sys.stderr)+        for c in checks:+            if c["status"] != "PASS":+                print(f"Hint: {c['status']} {c['check']}: {c['detail']}", file=sys.stderr)+        print(f"Hint: {worst}. Look at {rep.get('image')} (top view) and at the outline at widget size before you report.", file=sys.stderr)+    return rep+++def main(argv=None):+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)+    ap.add_argument("step"); ap.add_argument("--outline"); ap.add_argument("--footprint"); ap.add_argument("--plan"); ap.add_argument("--mpn")+    ap.add_argument("--no-render", action="store_true")+    a = ap.parse_args(argv)+    fp = a.footprint+    if not fp:+        d = os.path.dirname(os.path.abspath(a.step))+        c = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".kicad_mod")]+        if len(c) == 1:+            fp = c[0]+    rep = check(a.step, footprint=fp, outline=a.outline, mpn=a.mpn, plan_path=a.plan, render=not a.no_render)+    print(json.dumps(dict(status=rep["status"], report=rep["reportPath"], image=rep.get("image"), checks=[(c["check"], c["status"]) for c in rep["checks"]])))+    return 1 if rep["status"] == "FAIL" else 0+++if __name__ == "__main__":+    sys.exit(main())
planner/mark_etch.pyadded+162
@@ -0,0 +1,162 @@+#!/usr/bin/env python3+"""Etch an MPN onto a part STEP the planner's way, on the shared service only (no OpenCascade):++  1. plan (mark_plan.py): face, long axis, usable band, reading direction from pin 1, lines, size;+  2. turn the part (exact quarter turns on the STEP text): stand it up, bring a side face to the top for+     LEDs, and pose it so the reading direction is +X;+  3. POST /laser-etch?mode=flat to service-step2glb on a thin proxy plate laid on the planned band, then+     graft the real model (untouched, colours kept) under Adom.Chip next to the Adom.LaserEtch group;+  4. fit the service's mark into the band: line 2 brought up to line 1's size, the block scaled to fill+     the band minus margins, centred on it and seated on the face (coplanar, 5 um lift);+  5. turn everything back, so the output sits in the input's own frame (board model paths and+     transforms do not change), then run the legibility gate (mark_check.py) on the result."""+import json, os, sys++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+import step_text as ST+import mark_svc as SVC+import mark_plan as MP+import mark_geom as MG++LIFT = 0.005+PROXY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")+PROXY_T = 0.05+++def _rect_turn(r, q):+    pts = [MP._rot([x, y, 0], "z", q) for x, y in ((r[0], r[1]), (r[2], r[3]))]+    return [min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)]+++def _set_mark_colour(step, rgb):+    """Recolour the mark (sRGB 0..1) on its styled items; the default is the service's light grey-white."""+    ents = step.product_entities("Adom.LaserEtch") or set()+    for k in step.order:+        if step.kind(k) == "STYLED_ITEM" and set(step.refs(k)) & ents:+            for x in step.reach([k]):+                if step.kind(x) == "COLOUR_RGB":+                    nm = step.body[x].split(",", 1)[0]+                    step.body[x] = nm + "," + ",".join(ST.fmt(v) for v in rgb) + ")"+++def etch(src, mpn, out, footprint=None, face="auto", up="auto", side_band="high", along=None, terminations="geometry",+         margin_w=MP.MARGIN_W, margin_h=MP.MARGIN_H, lines=None, over_vendor_mark=False, keep_old_mark=False, mark=None, log=print):+    plan = MP.plan(src, footprint, mpn, face, up, side_band, margin_w, margin_h, along=along, terminations=terminations)+    if plan["vendorMarking"]["present"] and not over_vendor_mark:+        raise SystemExit("ERROR: " + plan["warnings"][0] + "\nHint: leave this part's vendor model as it is; pass --over-vendor-mark only if the user asks for an Adom mark on top.")+    text_lines = lines or plan["text"]["lines"]+    s = ST.Step(src)+    s.flatten()+    removed = []+    for nm in plan["existingMarks"]:+        if not keep_old_mark:+            s.remove_product(nm); removed.append(nm)+    turns = [(t["axis"], t["quarterTurns"]) for t in plan["standUp"]]+    st = plan["band"]["sideTurn"]+    if st:+        turns.append((st["axis"], st["quarterTurns"]))+    qz = plan["etch"]["poseQuarterTurnsZ"]+    if qz:+        turns.append(("z", qz))+    for ax, q in turns:+        s.rotate90(ax, q)+    band = _rect_turn(plan["band"]["regionMm"], qz)+    bz = plan["band"]["zMm"]+    # the service etches a thin PROXY plate laid exactly on the band (any model works, B-spline faces+    # included, and the service can never pick a cap, a lens or a pin-1 dot); the real model is then+    # grafted, untouched, under Adom.Chip+    proxy = ST.Step(PROXY).scale_axes(band[0], band[1], bz - PROXY_T, band[2] - band[0], band[3] - band[1], PROXY_T)+    etched = SVC.laser_etch(proxy.text, text_lines[0], text_lines[1] if len(text_lines) > 1 else None, job=f"etch-{mpn}")+    e = ST.Step(etched)+    e.flatten()+    m = MG.analyse(e, "Adom.LaserEtch")+    if not m:+        raise SystemExit("ERROR: the service returned no Adom.LaserEtch group")+    # 1) the service writes along its own idea of the long axis: turn the mark so it reads +X+    q_mark = MP.quarter_turns_to(m["u"], (1, 0))+    if q_mark:+        e.transform_product("Adom.LaserEtch", m["centre"], 1.0, q_mark)+        m = MG.analyse(e, "Adom.LaserEtch")+    # 2) even out the lines: the service draws line 2 at about 0.62 of line 1+    if len(m["lines"]) == 2:+        l1, l2 = m["lines"]+        k = l1["capMm"] / max(l2["capMm"], 1e-9)+        ents2 = e.faces_subset_entities("Adom.LaserEtch", l2["faces"])+        c2 = [l2["centre"][0], l2["centre"][1], m["z"]]+        gap = MP.GAP * l1["capMm"]+        new_top = l1["box"][1] - gap+        new_h = (l2["box"][3] - l2["box"][1]) * k+        dy = (new_top - new_h / 2) - c2[1]+        dx = l1["centre"][0] - c2[0]+        e.transform_entities(ents2, c2, k, (dx, dy, 0))+        m = MG.analyse(e, "Adom.LaserEtch")+    # 3) fill the band minus margins, centred, seated on the face+    bw, bh = band[2] - band[0], band[3] - band[1]+    mw_, mh_ = m["bbox"][3] - m["bbox"][0], m["bbox"][4] - m["bbox"][1]+    scale = min(bw * (1 - 2 * margin_w) / mw_, bh * (1 - 2 * margin_h) / mh_)+    c = m["centre"]+    target = [(band[0] + band[2]) / 2, (band[1] + band[3]) / 2]+    e.transform_product("Adom.LaserEtch", (c[0], c[1], c[2]), scale, 0, (target[0] - c[0], target[1] - c[1], 0), set_z=bz + LIFT)+    fitted = MG.analyse(e, "Adom.LaserEtch")+    e.graft(s, "Adom.Chip")+    # 4) back to the input's frame+    for ax, q in reversed(turns):+        e.rotate90(ax, -q)+    if mark not in (None, "light"):+        rgb = {"dark": [0.11, 0.11, 0.11]}.get(mark) or [float(v) for v in mark.split(",")]+        _set_mark_colour(e, rgb)+    e.write(out)+    info = dict(out=os.path.abspath(out), mpn=mpn, lines=text_lines, removedOldMarks=removed, turns=[dict(axis=a, quarterTurns=q) for a, q in turns],+                serviceMarkTurnedQuarter=q_mark, line2Scaled=len(text_lines) == 2,+                fit=dict(scale=round(scale, 4), capMm=round(fitted["capMm"], 4), markMm=[round(fitted["bbox"][3] - fitted["bbox"][0], 4), round(fitted["bbox"][4] - fitted["bbox"][1], 4)],+                         bandMm=[round(bw, 4), round(bh, 4)], zMm=round(bz + LIFT, 4)))+    return plan, info+++def main(argv=None):+    import argparse+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)+    ap.add_argument("--in", dest="src", required=True); ap.add_argument("--mpn", required=True); ap.add_argument("--out", required=True)+    ap.add_argument("--footprint", help="the part's .kicad_mod: pin 1 / pad 1, pads and the model rotation (strongly recommended; a single .kicad_mod beside the STEP is used automatically)")+    ap.add_argument("--face", default="auto", choices=["auto", "top", "side"])+    ap.add_argument("--up", default="auto", choices=["auto", "z", "y"])+    ap.add_argument("--side-band", default="high", choices=["high", "low"])+    ap.add_argument("--along", choices=["long", "x", "y"], help="OVERRIDE the planner's axis (x/y in the stood-up frame); warns when it puts the text across the short side")+    ap.add_argument("--terminations", default="geometry", choices=["geometry", "pads"])+    ap.add_argument("--margin-w", type=float, default=MP.MARGIN_W); ap.add_argument("--margin-h", type=float, default=MP.MARGIN_H)+    ap.add_argument("--lines", help="force the text lines, '|' separated (default: the planner's choice)")+    ap.add_argument("--over-vendor-mark", action="store_true"); ap.add_argument("--keep-old-mark", action="store_true")+    ap.add_argument("--mark", default="light", help="light (default, the rule) | dark (only on a light body) | r,g,b sRGB 0..1")+    ap.add_argument("--frac", help=argparse.SUPPRESS); ap.add_argument("--side-dir", help=argparse.SUPPRESS); ap.add_argument("--service", help=argparse.SUPPRESS)+    ap.add_argument("--no-check", action="store_true", help="skip the legibility gate (not recommended)")+    ap.add_argument("--outline", help="also check this outline SVG against the mark")+    a = ap.parse_args(argv)+    fp = a.footprint+    if not fp:+        d = os.path.dirname(os.path.abspath(a.src))+        c = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".kicad_mod")]+        if len(c) == 1:+            fp = c[0]; print(f"Hint: using footprint {fp} (the only .kicad_mod beside the STEP)", file=sys.stderr)+    for flag in ("frac", "side_dir", "service"):+        if getattr(a, flag):+            print(f"Hint: --{flag.replace('_', '-')} is ignored now: the planner sizes the text to the band and picks the front wall from pin 1 (adom-aiflow-etch --legacy keeps the old OpenCascade path)", file=sys.stderr)+    along = None if a.along in (None, "long") else a.along+    plan, info = etch(a.src, a.mpn, a.out, fp, a.face, a.up, a.side_band, along, a.terminations, a.margin_w, a.margin_h,+                      a.lines.split("|") if a.lines else None, a.over_vendor_mark, a.keep_old_mark, a.mark)+    for w in plan["warnings"]:+        print("Hint: WARNING " + w, file=sys.stderr)+    plan_path = os.path.splitext(a.out)[0] + ".mark-plan.json"+    json.dump(dict(plan=plan, etch=info), open(plan_path, "w"), indent=1)+    result = dict(ok=True, out=info["out"], plan=plan_path, summary=plan["summary"], etch=info)+    rc = 0+    if not a.no_check:+        import mark_check as MC+        rep = MC.check(a.out, plan=plan, footprint=fp, outline=a.outline, mpn=a.mpn)+        result["check"] = dict(status=rep["status"], report=rep["reportPath"], image=rep.get("image"))+        rc = 0 if rep["status"] != "FAIL" else 1+    print(json.dumps(result))+    return rc+++if __name__ == "__main__":+    sys.exit(main())
planner/mark_geom.pyadded+114
@@ -0,0 +1,114 @@+#!/usr/bin/env python3+"""Read a flat laser mark's geometry from STEP text: glyph faces, reading direction, lines, cap height,+plane height and colour. Used by the etch (to fit the service's mark into the planned band) and by the+legibility gate. Works in any frame where the mark lies in a horizontal plane."""+import math, re, statistics+import step_text as ST++MARK_NAMES = ("Adom.LaserEtch", "laser_etch")+++def mark_name(step):+    names = [n for _, n in step.products()]+    for n in MARK_NAMES:+        if n in names:+            return n+    return None+++def analyse(step, name=None):+    name = name or mark_name(step)+    if not name:+        return None+    ents = step.product_entities(name)+    ents -= step._frame_ents(step.rep_frames()) if getattr(step, "_flat", False) else set()+    faces = []+    for f in step.faces_points(ents):+        if len(f["points"]) < 2:+            continue+        b = ST.bbox(f["points"])+        faces.append(dict(id=f["id"], bbox=b, c=[(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2]))+    if not faces:+        return None+    allp = [f["bbox"] for f in faces]+    bb = [min(b[0] for b in allp), min(b[1] for b in allp), min(b[2] for b in allp), max(b[3] for b in allp), max(b[4] for b in allp), max(b[5] for b in allp)]+    sx = statistics.pstdev([f["c"][0] for f in faces]) if len(faces) > 1 else bb[3] - bb[0]+    sy = statistics.pstdev([f["c"][1] for f in faces]) if len(faces) > 1 else bb[4] - bb[1]+    axis = "x" if sx >= sy else "y"+    ai = 0 if axis == "x" else 1+    mid = (bb[ai] + bb[ai + 3]) / 2+    first = faces[0]["c"][ai]+    sign = 1 if first <= mid else -1          # the service writes glyphs in reading order+    u = [0.0, 0.0, 0.0]; u[ai] = float(sign)+    v = [-u[1], u[0], 0.0]+    vi = 1 - ai; vs = v[vi]+    # lines: cluster glyph centres along the text-up direction+    ext_v = [f["bbox"][vi + 3] - f["bbox"][vi] for f in faces]+    hmax = max(ext_v)+    order = sorted(faces, key=lambda f: f["c"][vi] * vs, reverse=True)       # top line first+    gaps = [(order[i]["c"][vi] * vs - order[i + 1]["c"][vi] * vs, i) for i in range(len(order) - 1)]+    lines = [order]+    if gaps:+        g, i = max(gaps)+        if g > 0.6 * hmax:+            lines = [order[: i + 1], order[i + 1:]]+    out_lines = []+    for ln in lines:+        e = [f["bbox"][vi + 3] - f["bbox"][vi] for f in ln]+        big = [x for x in e if x >= 0.6 * max(e)]+        lo = min(f["bbox"][ai] for f in ln); hi = max(f["bbox"][ai + 3] for f in ln)+        vlo = min(f["bbox"][vi] for f in ln); vhi = max(f["bbox"][vi + 3] for f in ln)+        out_lines.append(dict(faces=[f["id"] for f in ln], capMm=statistics.median(big), lengthMm=hi - lo,+                              box=[min(f["bbox"][0] for f in ln), min(f["bbox"][1] for f in ln), max(f["bbox"][3] for f in ln), max(f["bbox"][4] for f in ln)],+                              centre=[(min(f["bbox"][0] for f in ln) + max(f["bbox"][3] for f in ln)) / 2, (min(f["bbox"][1] for f in ln) + max(f["bbox"][4] for f in ln)) / 2]))+    return dict(name=name, faces=faces, bbox=bb, axis=axis, u=u, v=v, lines=out_lines, z=bb[5], flatMm=bb[5] - bb[2],+                capMm=min(l["capMm"] for l in out_lines), centre=[(bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2, (bb[2] + bb[5]) / 2])+++def colour(step, name):+    """sRGB of the mark (STEP COLOUR_RGB on styled items of its geometry), or None."""+    ents = step.product_entities(name) or set()+    for k in step.order:+        if step.kind(k) == "STYLED_ITEM" and set(step.refs(k)) & ents:+            for x in step.reach([k]):+                if step.kind(x) == "COLOUR_RGB":+                    m = re.findall(r"[-+0-9.Ee]+", step.body[x].split(",", 1)[1])+                    return [float(t) for t in m[:3]]+    return None+++# ---- iso outline projection (service-step2glb /outline camera looks from +X+Y+Z; SVG y down) ----------+R2, R6 = math.sqrt(2), math.sqrt(6)+++def iso(p):+    x, y, z = p+    return ((-x + y) / R2, (x + y - 2 * z) / R6)+++def iso_inv(X, Y, z):+    """The point on the plane Z = z that projects to (X, Y)."""+    a = R6 * Y + 2 * z; b = R2 * X+    return [(a - b) / 2, (a + b) / 2, z]+++def vertices(st):+    out = []+    for k in st.order:+        if st.kind(k) == "VERTEX_POINT":+            c = st.coords(st.refs(k)[0])+            if c and len(c) == 3:+                out.append(c)+    return out+++def calibrate(svg_polys, pts3):+    """Scale and offset of an outline drawing: its strokes' extents against the model's projected points+    (orthographic, so one scale; `agree` is the X/Y scale mismatch, a self-check)."""+    P = [p for t in svg_polys for p in t["pts"]]+    V = [iso(v) for v in pts3]+    wx = max(v[0] for v in V) - min(v[0] for v in V); wy = max(v[1] for v in V) - min(v[1] for v in V)+    sx = (max(p[0] for p in P) - min(p[0] for p in P)) / wx; sy = (max(p[1] for p in P) - min(p[1] for p in P)) / wy+    s = (sx + sy) / 2+    o = (min(p[0] for p in P) - s * min(v[0] for v in V), min(p[1] for p in P) - s * min(v[1] for v in V))+    return dict(s=s, o=o, agree=abs(sx - sy) / s)
planner/mark_outline.pyadded+152
@@ -0,0 +1,152 @@+#!/usr/bin/env python3+"""Named chip outline that IS the model's mark: `adom-aiflow-etch outline <part.step> [--footprint fp.kicad_mod]+[--mpn MPN] [--etched <x-etched.step>] [--out-dir DIR] [--styles dark,blueprint,...|all]`.++How (all on the shared service, no OpenCascade):+  1. the part is stood up and turned to the planner's standard iso pose (pin 1 / pins 1 to 3 toward the+     viewer) and the service draws its plain outline;+  2. the service draws the name, in its single-stroke outline font, on a thin plate laid exactly on the+     planner's band (between the terminations, clear of the pin-1 dot, on the LED's front wall), in the+     frame where the text reads +X;+  3. both drawings are orthographic iso views of the same frame, so the name strokes are mapped exactly+     (inverse projection onto the band plane, the same quarter turns as the etch, projection into the+     part's drawing). The name runs along the long axis, reads upright, sits where the etched mark sits.+Every SVG is gated (mark_check.check_outline). A failing SVG is written as *.FAILED-GATE.svg with a loud+warning and Hint lines, never silently. If the calibration cannot be trusted, the outline of the posed+ETCHED STEP is used instead (the mark's own edges are the name)."""+import argparse, json, math, os, sys++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+import step_text as ST+import mark_svc as SVC+import mark_plan as MP+import mark_check as MC+import mark_geom as MG++PLATE_T = 0.01+iso, iso_inv, vertices, calibrate = MG.iso, MG.iso_inv, MG.vertices, MG.calibrate+++def frames(plan):+    """Quarter turns: stand-up frame -> etch frame (mark face +Z, reading +X) and -> outline pose."""+    to_e = []+    if plan["band"]["sideTurn"]:+        to_e.append((plan["band"]["sideTurn"]["axis"], plan["band"]["sideTurn"]["quarterTurns"]))+    if plan["etch"]["poseQuarterTurnsZ"]:+        to_e.append(("z", plan["etch"]["poseQuarterTurnsZ"]))+    q_o = plan["outline"]["poseRotZDeg"] // 90+    def e_to_o(p):+        for ax, q in reversed(to_e):+            p = MP._rot(p, ax, -q)+        return MP._rot(p, "z", q_o)+    return to_e, q_o, e_to_o+++def posed(path, plan, extra=()):+    s = ST.Step(path); s.flatten()+    for t in plan["standUp"]:+        s.rotate90(t["axis"], t["quarterTurns"])+    for ax, q in extra:+        s.rotate90(ax, q)+    return s+++def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, log=lambda m: print(m, file=sys.stderr), write_plain=False):+    stem = stem or plan["mpn"]+    to_e, q_o, e_to_o = frames(plan)+    body_step = posed(step, plan, [("z", q_o)])+    for nm in [n for _, n in body_step.products() if n in MG.MARK_NAMES]:+        body_step.remove_product(nm)              # an old mark must not show through+    body = SVC.outline(body_step.text, job=f"outline-{stem}-body")["outlines"]+    # the name, on a plate laid on the band in the etch frame (reading +X, rot 0)+    b = plan["band"]["regionMm"]; bz = plan["band"]["zMm"]+    qz = plan["etch"]["poseQuarterTurnsZ"]+    pts = [MP._rot([x, y, 0], "z", qz) for x, y in ((b[0], b[1]), (b[2], b[3]))]+    x0, y0 = min(p[0] for p in pts), min(p[1] for p in pts); x1, y1 = max(p[0] for p in pts), max(p[1] for p in pts)+    mw, mh = plan["band"]["margins"]["lengthEachSide"], plan["band"]["margins"]["heightEachSide"]+    # the plate is the text block's area (band minus margins), so the name has the etched mark's size+    px0, px1 = x0 + mw * (x1 - x0) * 0.5, x1 - mw * (x1 - x0) * 0.5+    py0, py1 = y0 + mh * (y1 - y0) * 0.5, y1 - mh * (y1 - y0) * 0.5+    plate = ST.Step(os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")).scale_axes(px0, py0, bz - PLATE_T, px1 - px0, py1 - py0, PLATE_T)+    L = plan["text"]["lines"]+    named = SVC.outline(plate.text, name=L[0], line2=L[1] if len(L) == 2 else None, rot=0, suffix="-named", job=f"outline-{stem}-name")["outlines"]+    plate_pts = [[x, y, z] for x in (px0, px1) for y in (py0, py1) for z in (bz - PLATE_T, bz)]+    verts = vertices(body_step)+    os.makedirs(out_dir, exist_ok=True)+    meta_p = SVC.step_meta(body_step.text, "outline-check-meta")+    centre_e = [(px0 + px1) / 2, (py0 + py1) / 2, bz]+    centre_o = e_to_o(centre_e)+    mk = None+    if etched:+        e = posed(etched, plan, [("z", q_o)]); a = MG.analyse(e)+        if a:+            mk = a["bbox"]; centre_o = a["centre"]+    written, mode = [], "composed: service body outline + service single-stroke name on the planned band"+    for style, svg_body in body.items():+        if styles and style not in styles:+            continue+        if write_plain:      # the plain outline in the same standard pose+            open(os.path.join(out_dir, f"{stem}-3d-outline-{style}.svg"), "w").write(svg_body)+        svg_name = named.get(style + "-named")+        out_svg = None+        if svg_name:+            txt, plate_body = MC.outline_text(svg_name)+            c1 = calibrate(MC._polylines(svg_body), verts)+            c2 = calibrate(plate_body, plate_pts) if plate_body else None+            if txt and c2 and c1["agree"] < 0.03 and c2["agree"] < 0.03:+                def m(p):+                    X = (p[0] - c2["o"][0]) / c2["s"]; Y = (p[1] - c2["o"][1]) / c2["s"]+                    q = iso(e_to_o(iso_inv(X, Y, bz)))+                    return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])+                extra = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in t["pts"]) + '"' + t["attrs"].rstrip("/").rstrip() + "/>" for t in txt)+                out_svg = svg_body.replace("</svg>", '<g id="adom-mark-name">' + extra + "</g></svg>")+        if out_svg is None:+            if not etched:+                log(f"Hint: WARNING could not compose the {style} name (calibration); pass --etched <part-etched.step> to draw the outline from the etched model")+                continue+            e = posed(etched, plan, [("z", q_o)])+            out_svg = SVC.outline(e.text, job=f"outline-{stem}-etched")["outlines"][style]+            mode = "etched STEP outline (the mark's own edges)"+        path = os.path.join(out_dir, f"{stem}-3d-outline-{style}-named.svg")+        open(path, "w").write(out_svg)+        rep = MC.check_outline(path, plan, centre_o, meta_p, mk, verts=verts)+        if rep["status"] == "FAIL":+            bad = path.replace("-named.svg", "-named.FAILED-GATE.svg")+            os.replace(path, bad); path = bad+            log(f"Hint: FAIL outline {os.path.basename(path)}: " + "; ".join(rep.get("hints", [])))+        written.append(dict(style=style, path=path, gate=rep))+    plan_path = os.path.join(out_dir, f"{stem}.mark-plan.json")+    json.dump(plan, open(plan_path, "w"), indent=1)+    return dict(mode=mode, plan=plan_path, outlines=written)+++def main(argv=None):+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)+    ap.add_argument("step"); ap.add_argument("--etched"); ap.add_argument("--footprint"); ap.add_argument("--mpn")+    ap.add_argument("--out-dir", default="."); ap.add_argument("--styles", default="dark", help="comma list, or 'all'")+    ap.add_argument("--face", default="auto", choices=["auto", "top", "side"]); ap.add_argument("--up", default="auto", choices=["auto", "z", "y"])+    ap.add_argument("--stem")+    ap.add_argument("--write-plain", action="store_true", help="also write the plain outlines (<stem>-3d-outline-<style>.svg) in the same pose")+    ap.add_argument("--posed-out", help="also write the STEP in the outline's standard pose (the etched one when given), for a shaded iso render with the same view")+    a = ap.parse_args(argv)+    fp = a.footprint+    if not fp:+        d = os.path.dirname(os.path.abspath(a.step))+        c = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".kicad_mod")]+        if len(c) == 1:+            fp = c[0]+    mpn = a.mpn or os.path.splitext(os.path.basename(a.step))[0].upper()+    plan = MP.plan(a.step, fp, mpn, a.face, a.up)+    r = outlines(a.step, plan, a.etched, a.out_dir, None if a.styles == "all" else tuple(a.styles.split(",")), a.stem, write_plain=a.write_plain)+    if a.posed_out:+        posed(a.etched or a.step, plan, [("z", plan["outline"]["poseRotZDeg"] // 90)]).write(a.posed_out)+    worst = "FAIL" if any(o["gate"]["status"] == "FAIL" for o in r["outlines"]) else ("PASS" if r["outlines"] else "FAIL")+    print(f"Hint: {plan['summary']}", file=sys.stderr)+    print(f"Hint: outline {worst} ({r['mode']}); look at it at 320 px before you report.", file=sys.stderr)+    print(json.dumps(dict(status=worst, mode=r["mode"], plan=r["plan"], outlines=[dict(path=o["path"], status=o["gate"]["status"],+                                                                                         capPx=o["gate"].get("capHeightPx320"), offsetPct=o["gate"].get("offsetPct")) for o in r["outlines"]])))+    return 1 if worst == "FAIL" else 0+++if __name__ == "__main__":+    sys.exit(main())
planner/mark_plan.pyadded+515
@@ -0,0 +1,515 @@+#!/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()
planner/mark_svc.pyadded+68
@@ -0,0 +1,68 @@+#!/usr/bin/env python3+"""Client for the shared Adom STEP service (service-step2glb): /step-meta, /laser-etch, /thumbnail and+/outline, sync (200) or async (202 + job, polled). Stdlib only. Every POST names its caller with X-Client+and X-Job-Name, as the service asks."""+import json, os, time, urllib.error, urllib.parse, urllib.request++URL = os.environ.get("STEP2GLB_SERVICE_API", "https://step2glb-gmdoncpxdwx0.adom.cloud").rstrip("/")+CLIENT = os.environ.get("AIFLOW_ETCH_CLIENT", "adom-aiflow-etch/" + os.environ.get("USER", "user"))+++def _get(path, job, timeout=60):+    req = urllib.request.Request(URL + path, headers={"X-Client": CLIENT, "X-Job-Name": job})+    return urllib.request.urlopen(req, timeout=timeout)+++def call(path, body, job, retries=2):+    """POST a STEP (bytes or str) to `path` and return (bytes, headers)."""+    if isinstance(body, str):+        body = body.encode("latin-1")+    last = None+    for attempt in range(retries + 1):+        try:+            req = urllib.request.Request(URL + path, data=body, method="POST", headers={+                "Content-Type": "application/step", "X-Client": CLIENT, "X-Job-Name": job[:120]})+            r = urllib.request.urlopen(req, timeout=240)+            data, hdr = r.read(), dict(r.headers)+            if r.status != 202:+                return data, hdr+            jid = json.loads(data)["job_id"]+            for _ in range(240):+                v = json.loads(_get(f"/jobs/{jid}", "poll", 30).read())+                st = v.get("status")+                if st in ("complete", "done", "succeeded"):+                    r2 = _get(f"/jobs/{jid}/result", "result", 120)+                    return r2.read(), dict(r2.headers)+                if st in ("failed", "error"):+                    raise RuntimeError(f"service job {jid} failed: {v.get('error')}")+                time.sleep(1.5)+            raise RuntimeError(f"service job {jid} timed out")+        except urllib.error.HTTPError as e:+            last = RuntimeError(f"POST {path} -> HTTP {e.code}: {e.read()[:300]!r}")+            if e.code < 500:+                raise last+        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:+            last = RuntimeError(f"POST {path}: {e}")+        time.sleep(3 * (attempt + 1))+    raise last+++def q(**kw):+    return urllib.parse.urlencode({k: v for k, v in kw.items() if v is not None})+++def step_meta(step, job="mark-plan-meta"):+    return json.loads(call("/step-meta", step, job)[0])+++def laser_etch(step, line1, line2=None, job="mark-etch"):+    return call("/laser-etch?" + q(name=line1, line2=line2, mode="flat"), step, job)[0].decode("latin-1")+++def thumbnail(step, pose="top", w=640, h=640, bg=None, name=None, rot=None, job="mark-check-view"):+    data, hdr = call("/thumbnail?" + q(pose=pose, kind="step", upAxis="asIs", width=w, height=h, bg=bg, name=name, rot=rot), step, job)+    return data, hdr+++def outline(step, name=None, rot=None, suffix=None, line2=None, job="mark-outline"):+    return json.loads(call("/outline?" + q(upAxis="z", name=name, line2=line2, rot=rot, suffix=suffix), step, job)[0])
planner/proxy_box.stepadded+187
@@ -0,0 +1,187 @@+ISO-10303-21;+HEADER;+FILE_DESCRIPTION(('Open CASCADE Model'),'2;1');+FILE_NAME('Open CASCADE Shape Model','2026-10-03T08:13:11',('Author'),(+    'Open CASCADE'),'Open CASCADE STEP processor 8.0','Open CASCADE 8.0'+  ,'Unknown');+FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));+ENDSEC;+DATA;+#1 = APPLICATION_PROTOCOL_DEFINITION('international standard',+  'automotive_design',2000,#2);+#2 = APPLICATION_CONTEXT(+  'core data for automotive mechanical design processes');+#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10);+#4 = PRODUCT_DEFINITION_SHAPE('','',#5);+#5 = PRODUCT_DEFINITION('design','',#6,#9);+#6 = PRODUCT_DEFINITION_FORMATION('','',#7);+#7 = PRODUCT('adom_mark_proxy','adom_mark_proxy','',(#8));+#8 = PRODUCT_CONTEXT('',#2,'mechanical');+#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design');+#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#345);+#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14);+#12 = CARTESIAN_POINT('',(0.,0.,0.));+#13 = DIRECTION('',(0.,0.,1.));+#14 = DIRECTION('',(1.,0.,-0.));+#15 = MANIFOLD_SOLID_BREP('',#16);+#16 = CLOSED_SHELL('',(#17,#137,#237,#284,#331,#338));+#17 = ADVANCED_FACE('',(#18),#32,.F.);+#18 = FACE_BOUND('',#19,.F.);+#19 = EDGE_LOOP('',(#20,#55,#83,#111));+#20 = ORIENTED_EDGE('',*,*,#21,.F.);+#21 = EDGE_CURVE('',#22,#24,#27,.T.);+#22 = VERTEX_POINT('',#23);+#23 = CARTESIAN_POINT('',(0.,0.,0.));+#24 = VERTEX_POINT('',#25);+#25 = CARTESIAN_POINT('',(0.,0.,1.));+#27 = LINE('',#28,#29);+#28 = CARTESIAN_POINT('',(0.,0.,0.));+#29 = VECTOR('',#30,1.);+#30 = DIRECTION('',(0.,0.,1.));+#32 = PLANE('',#33);+#33 = AXIS2_PLACEMENT_3D('',#34,#35,#36);+#34 = CARTESIAN_POINT('',(0.,0.,0.));+#35 = DIRECTION('',(1.,0.,-0.));+#36 = DIRECTION('',(0.,0.,1.));+#44 = PLANE('',#45);+#45 = AXIS2_PLACEMENT_3D('',#46,#47,#48);+#46 = CARTESIAN_POINT('',(0.,0.,0.));+#47 = DIRECTION('',(-0.,1.,0.));+#48 = DIRECTION('',(0.,0.,1.));+#55 = ORIENTED_EDGE('',*,*,#56,.T.);+#56 = EDGE_CURVE('',#22,#57,#60,.T.);+#57 = VERTEX_POINT('',#58);+#58 = CARTESIAN_POINT('',(0.,1.,0.));+#60 = LINE('',#61,#62);+#61 = CARTESIAN_POINT('',(0.,0.,0.));+#62 = VECTOR('',#63,1.);+#63 = DIRECTION('',(-0.,1.,0.));+#72 = PLANE('',#73);+#73 = AXIS2_PLACEMENT_3D('',#74,#75,#76);+#74 = CARTESIAN_POINT('',(0.,0.,0.));+#75 = DIRECTION('',(0.,0.,1.));+#76 = DIRECTION('',(1.,0.,-0.));+#83 = ORIENTED_EDGE('',*,*,#84,.T.);+#84 = EDGE_CURVE('',#57,#85,#88,.T.);+#85 = VERTEX_POINT('',#86);+#86 = CARTESIAN_POINT('',(0.,1.,1.));+#88 = LINE('',#89,#90);+#89 = CARTESIAN_POINT('',(0.,1.,0.));+#90 = VECTOR('',#91,1.);+#91 = DIRECTION('',(0.,0.,1.));+#100 = PLANE('',#101);+#101 = AXIS2_PLACEMENT_3D('',#102,#103,#104);+#102 = CARTESIAN_POINT('',(0.,1.,0.));+#103 = DIRECTION('',(-0.,1.,0.));+#104 = DIRECTION('',(0.,0.,1.));+#111 = ORIENTED_EDGE('',*,*,#112,.F.);+#112 = EDGE_CURVE('',#24,#85,#114,.T.);+#114 = LINE('',#115,#116);+#115 = CARTESIAN_POINT('',(0.,0.,1.));+#116 = VECTOR('',#117,1.);+#117 = DIRECTION('',(-0.,1.,0.));+#126 = PLANE('',#127);+#127 = AXIS2_PLACEMENT_3D('',#128,#129,#130);+#128 = CARTESIAN_POINT('',(0.,0.,1.));+#129 = DIRECTION('',(0.,0.,1.));+#130 = DIRECTION('',(1.,0.,-0.));+#137 = ADVANCED_FACE('',(#138),#152,.T.);+#138 = FACE_BOUND('',#139,.T.);+#139 = EDGE_LOOP('',(#140,#170,#193,#216));+#140 = ORIENTED_EDGE('',*,*,#141,.F.);+#141 = EDGE_CURVE('',#142,#144,#147,.T.);+#142 = VERTEX_POINT('',#143);+#143 = CARTESIAN_POINT('',(1.,0.,0.));+#144 = VERTEX_POINT('',#145);+#145 = CARTESIAN_POINT('',(1.,0.,1.));+#147 = LINE('',#148,#149);+#148 = CARTESIAN_POINT('',(1.,0.,0.));+#149 = VECTOR('',#150,1.);+#150 = DIRECTION('',(0.,0.,1.));+#152 = PLANE('',#153);+#153 = AXIS2_PLACEMENT_3D('',#154,#155,#156);+#154 = CARTESIAN_POINT('',(1.,0.,0.));+#155 = DIRECTION('',(1.,0.,-0.));+#156 = DIRECTION('',(0.,0.,1.));+#170 = ORIENTED_EDGE('',*,*,#171,.T.);+#171 = EDGE_CURVE('',#142,#172,#175,.T.);+#172 = VERTEX_POINT('',#173);+#173 = CARTESIAN_POINT('',(1.,1.,0.));+#175 = LINE('',#176,#177);+#176 = CARTESIAN_POINT('',(1.,0.,0.));+#177 = VECTOR('',#178,1.);+#178 = DIRECTION('',(-0.,1.,0.));+#193 = ORIENTED_EDGE('',*,*,#194,.T.);+#194 = EDGE_CURVE('',#172,#195,#198,.T.);+#195 = VERTEX_POINT('',#196);+#196 = CARTESIAN_POINT('',(1.,1.,1.));+#198 = LINE('',#199,#200);+#199 = CARTESIAN_POINT('',(1.,1.,0.));+#200 = VECTOR('',#201,1.);+#201 = DIRECTION('',(0.,0.,1.));+#216 = ORIENTED_EDGE('',*,*,#217,.F.);+#217 = EDGE_CURVE('',#144,#195,#219,.T.);+#219 = LINE('',#220,#221);+#220 = CARTESIAN_POINT('',(1.,0.,1.));+#221 = VECTOR('',#222,1.);+#222 = DIRECTION('',(-0.,1.,0.));+#237 = ADVANCED_FACE('',(#238),#44,.F.);+#238 = FACE_BOUND('',#239,.F.);+#239 = EDGE_LOOP('',(#240,#261,#262,#283));+#240 = ORIENTED_EDGE('',*,*,#241,.F.);+#241 = EDGE_CURVE('',#22,#142,#243,.T.);+#243 = LINE('',#244,#245);+#244 = CARTESIAN_POINT('',(0.,0.,0.));+#245 = VECTOR('',#246,1.);+#246 = DIRECTION('',(1.,0.,-0.));+#261 = ORIENTED_EDGE('',*,*,#21,.T.);+#262 = ORIENTED_EDGE('',*,*,#263,.T.);+#263 = EDGE_CURVE('',#24,#144,#265,.T.);+#265 = LINE('',#266,#267);+#266 = CARTESIAN_POINT('',(0.,0.,1.));+#267 = VECTOR('',#268,1.);+#268 = DIRECTION('',(1.,0.,-0.));+#283 = ORIENTED_EDGE('',*,*,#141,.F.);+#284 = ADVANCED_FACE('',(#285),#100,.T.);+#285 = FACE_BOUND('',#286,.T.);+#286 = EDGE_LOOP('',(#287,#308,#309,#330));+#287 = ORIENTED_EDGE('',*,*,#288,.F.);+#288 = EDGE_CURVE('',#57,#172,#290,.T.);+#290 = LINE('',#291,#292);+#291 = CARTESIAN_POINT('',(0.,1.,0.));+#292 = VECTOR('',#293,1.);+#293 = DIRECTION('',(1.,0.,-0.));+#308 = ORIENTED_EDGE('',*,*,#84,.T.);+#309 = ORIENTED_EDGE('',*,*,#310,.T.);+#310 = EDGE_CURVE('',#85,#195,#312,.T.);+#312 = LINE('',#313,#314);+#313 = CARTESIAN_POINT('',(0.,1.,1.));+#314 = VECTOR('',#315,1.);+#315 = DIRECTION('',(1.,0.,-0.));+#330 = ORIENTED_EDGE('',*,*,#194,.F.);+#331 = ADVANCED_FACE('',(#332),#72,.F.);+#332 = FACE_BOUND('',#333,.F.);+#333 = EDGE_LOOP('',(#334,#335,#336,#337));+#334 = ORIENTED_EDGE('',*,*,#56,.F.);+#335 = ORIENTED_EDGE('',*,*,#241,.T.);+#336 = ORIENTED_EDGE('',*,*,#171,.T.);+#337 = ORIENTED_EDGE('',*,*,#288,.F.);+#338 = ADVANCED_FACE('',(#339),#126,.T.);+#339 = FACE_BOUND('',#340,.T.);+#340 = EDGE_LOOP('',(#341,#342,#343,#344));+#341 = ORIENTED_EDGE('',*,*,#112,.F.);+#342 = ORIENTED_EDGE('',*,*,#263,.T.);+#343 = ORIENTED_EDGE('',*,*,#217,.T.);+#344 = ORIENTED_EDGE('',*,*,#310,.F.);+#345 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#349)) GLOBAL_UNIT_ASSIGNED_CONTEXT+((#346,#347,#348)) REPRESENTATION_CONTEXT('Context #1',+  '3D Context with UNIT and UNCERTAINTY') );+#346 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) );+#347 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) );+#348 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() );+#349 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#346,+  'distance_accuracy_value','confusion accuracy');+#350 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7));+ENDSEC;+END-ISO-10303-21;
planner/step_text.pyadded+629
@@ -0,0 +1,629 @@+#!/usr/bin/env python3+"""Pure-text STEP transforms (no OpenCascade): the mark planner and the etch run on the shared services,+and the only local geometry work they need is exact and small enough to do on the STEP text itself.++  * rotate the whole model by a multiple of 90 degrees about X, Y or Z (stand a Y-up model up, turn a+    side face to the top, pose the part so its mark reads along +X): every 3-D CARTESIAN_POINT and+    DIRECTION is permuted and negated, which is exact, and conjugates every assembly placement, so+    colours, names and the assembly tree are untouched;+  * move one product's geometry (the Adom.LaserEtch mark) by turn + uniform scale + translation about a+    centre: its own points, directions and VECTOR magnitudes change, pcurve (2-D) points scale with it,+    and entities shared with other products (the frame origin) are left alone;+  * read a product's points per face (glyph centroids and extents for the legibility gate).++Python API: Step(path_or_text) .rotate90(axis, quarter_turns) .transform_product(name, ...) .text+CLI:  step_text.py rotate in.step out.step <x|y|z> <quarter_turns>+      step_text.py products in.step"""+import math, re, sys++_ENT = re.compile(r"#(\d+)\s*=\s*", re.S)+_REF = re.compile(r"#(\d+)")+++def _split_statements(data):+    """Split the DATA section into statements at ';' outside quoted strings."""+    out, buf, i, n, inq = [], [], 0, len(data), False+    start = 0+    while i < n:+        c = data[i]+        if inq:+            if c == "'":+                if i + 1 < n and data[i + 1] == "'":+                    i += 2; continue+                inq = False+        elif c == "'":+            inq = True+        elif c == ";":+            out.append(data[start:i]); start = i + 1+        i += 1+    tail = data[start:]+    return out, tail+++def fmt(v):+    """A STEP REAL: always with a decimal point, exponent as E."""+    if v == 0:+        return "0."+    s = repr(float(v))+    if "e" in s or "E" in s:+        m, e = s.lower().split("e")+        if "." not in m:+            m += "."+        return f"{m}E{int(e):+03d}".replace("E+", "E+")+    return s if "." in s else s + "."+++def _nums(body):+    """The numbers of the coordinate list in CARTESIAN_POINT('n',(a,b,c)) / DIRECTION('n',(a,b,c))."""+    m = re.search(r"\(\s*'(?:[^']|'')*'\s*,\s*\(([^()]*)\)\s*\)\s*$", body, re.S)+    if not m:+        return None, None+    return [float(t) for t in m.group(1).replace("\n", " ").split(",")], m.span(1)+++class Step:+    def __init__(self, src):+        text = src if "ISO-10303-21" in src[:200] else open(src, encoding="latin-1").read()+        i = text.index("DATA;") + 5+        j = text.index("ENDSEC;", i)+        self.head, self.tail = text[:i], text[j:]+        stmts, rest = _split_statements(text[i:j])+        self.order, self.body, self.lead = [], {}, {}+        for s in stmts:+            m = _ENT.search(s)+            if not m:+                continue+            k = int(m.group(1))+            self.order.append(k); self.body[k] = s[m.end():].strip(); self.lead[k] = s[:m.start()]+        self.rest = rest+        self._rev = None++    # ---- graph -------------------------------------------------------------------------------+    def kind(self, k):+        b = self.body[k]+        return b[: b.index("(")].strip() if not b.startswith("(") else ""++    def refs(self, k):+        return [int(x) for x in _REF.findall(re.sub(r"'(?:[^']|'')*'", "", self.body[k]))]++    def rev(self):+        if self._rev is None:+            r = {}+            for k in self.order:+                for x in self.refs(k):+                    r.setdefault(x, []).append(k)+            self._rev = r+        return self._rev++    def reach(self, roots, stop=()):+        seen, st = set(), list(roots)+        while st:+            k = st.pop()+            if k in seen or k not in self.body or k in stop:+                continue+            seen.add(k); st.extend(self.refs(k))+        return seen++    def products(self):+        out = []+        for k in self.order:+            if self.kind(k) == "PRODUCT":+                m = re.match(r"PRODUCT\s*\(\s*'((?:[^']|'')*)'", self.body[k])+                out.append((k, m.group(1).replace("''", "'") if m else ""))+        return out++    def _up(self, k, kind_prefix):+        return [x for x in self.rev().get(k, []) if self.kind(x).startswith(kind_prefix) and self.kind(x) == kind_prefix]++    def product_reps(self, pid):+        """Representations (and SRR-linked breps) that carry this product's own geometry."""+        reps = []+        for pdf in self._up(pid, "PRODUCT_DEFINITION_FORMATION") + self._up(pid, "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE"):+            for pd in self._up(pdf, "PRODUCT_DEFINITION"):+                for pds in self._up(pd, "PRODUCT_DEFINITION_SHAPE"):+                    for sdr in self._up(pds, "SHAPE_DEFINITION_REPRESENTATION"):+                        reps.append(self.refs(sdr)[-1])+        more = list(reps)+        while more:+            r = more.pop()+            for x in self.rev().get(r, []):+                if self.kind(x) == "SHAPE_REPRESENTATION_RELATIONSHIP":+                    a = self.refs(x)+                    for y in a:+                        if y != r and y not in reps:+                            reps.append(y); more.append(y)+        return reps++    def _rep_items(self, rep):+        """Items of a representation, without its context (last ref)."""+        r = self.refs(rep)+        return r[:-1]++    def product_entities(self, name, pids=None):+        """Entities that belong only to the named product's geometry (shared frames and contexts excluded)."""+        prods = self.products()+        mine = list(pids) if pids else [p for p, nm in prods if nm == name]+        if not mine:+            return None+        my_reps = set(r for p in mine for r in self.product_reps(p))+        other_reps = set(r for p, nm in prods if p not in mine for r in self.product_reps(p)) - my_reps+        roots_m = [i for r in my_reps for i in self._rep_items(r)]+        roots_o = [i for r in other_reps for i in self._rep_items(r)]+        # anything referenced from outside the product reps (assembly placements, styling) is shared too+        a = self.reach(roots_m)+        b = self.reach(roots_o)+        shared = set()+        for k in self.order:+            if k in a or k in b:+                continue+            kd = self.kind(k)+            if kd == "ITEM_DEFINED_TRANSFORMATION":      # assembly placements: frames shared with the parent+                shared |= self.reach(self.refs(k))+        return a - b - shared++    # ---- numeric access ----------------------------------------------------------------------+    def coords(self, k):+        return _nums(self.body[k])[0]++    def set_coords(self, k, vals):+        nums, span = _nums(self.body[k])+        b = self.body[k]+        self.body[k] = b[: span[0]] + ",".join(fmt(v) for v in vals) + b[span[1]:]++    def faces_points(self, ents):+        """For each ADVANCED_FACE (or FACE_SURFACE) in `ents`, in file order: its 3-D boundary points."""+        out = []+        for k in self.order:+            if k in ents and self.kind(k) in ("ADVANCED_FACE", "FACE_SURFACE"):+                pts = []+                fb = [x for x in self.refs(k) if self.kind(x) in ("FACE_BOUND", "FACE_OUTER_BOUND")]+                for x in self.reach(fb):+                    if self.kind(x) == "CARTESIAN_POINT":+                        c = self.coords(x)+                        if c and len(c) == 3:+                            pts.append(c)+                out.append(dict(id=k, points=pts))+        return out++    # ---- transforms --------------------------------------------------------------------------+    # ---- frames and flattening ---------------------------------------------------------------+    def _new_id(self):+        self._next = max(getattr(self, "_next", 0), max(self.order)) + 1+        return self._next++    def _add(self, body, after=None):+        k = self._new_id(); self.body[k] = body; self.lead[k] = ""+        self.order.append(k); self._rev = None+        return k++    def rep_frames(self):+        """AXIS2_PLACEMENT_3D entities listed directly among a representation's items (its own frame and+        the frames assembly transforms point at)."""+        out = set()+        for k in self.order:+            if self.kind(k).endswith("REPRESENTATION") and "SHAPE" in self.kind(k) or self.kind(k) == "SHAPE_REPRESENTATION":+                for x in self._rep_items(k):+                    if x in self.body and self.kind(x) == "AXIS2_PLACEMENT_3D":+                        out.add(x)+        return out++    def _frame(self, x):+        o, z, xd = [self.coords(r) for r in self.refs(x)[:3]] if len(self.refs(x)) >= 3 else (self.coords(self.refs(x)[0]), [0, 0, 1], [1, 0, 0])+        def nrm(v):+            l = math.sqrt(sum(c * c for c in v)) or 1.0+            return [c / l for c in v]+        z = nrm(z); xd = [xd[i] - sum(xd[j] * z[j] for j in range(3)) * z[i] for i in range(3)]; xd = nrm(xd)+        y = [z[1] * xd[2] - z[2] * xd[1], z[2] * xd[0] - z[0] * xd[2], z[0] * xd[1] - z[1] * xd[0]]+        return o, [xd, y, z]          # columns: local X, Y, Z in the parent++    @staticmethod+    def _mat(F):+        o, (X, Y, Z) = F+        return [[X[0], Y[0], Z[0], o[0]], [X[1], Y[1], Z[1], o[1]], [X[2], Y[2], Z[2], o[2]], [0, 0, 0, 1]]++    @staticmethod+    def _mul(A, B):+        return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]++    @staticmethod+    def _inv(M):+        R = [[M[j][i] for j in range(3)] for i in range(3)]+        t = [-sum(R[i][k] * M[k][3] for k in range(3)) for i in range(3)]+        return [R[0] + [t[0]], R[1] + [t[1]], R[2] + [t[2]], [0, 0, 0, 1]]++    @staticmethod+    def _is_identity(M, tol=1e-9):+        return all(abs(M[i][j] - (1.0 if i == j else 0.0)) < tol for i in range(4) for j in range(4))++    def _isolate_frames(self):+        """Give every representation frame its own point and directions, so geometry never shares them."""+        for f in self.rep_frames():+            refs = self.refs(f)+            b = self.body[f]+            for r in refs:+                nk = self._add(self.body[r])+                b = re.sub(rf"#{r}(?!\d)", f"#{nk}", b, count=1)+            self.body[f] = b+        self._rev = None+        return self.rep_frames()++    def _frame_ents(self, frames):+        return set(x for f in frames for x in self.reach([f]))++    def flatten(self):+        """Bake assembly transforms (and a root representation's own frame, which OpenCascade applies as+        a placement) into the geometry and reset every frame to the identity. Afterwards local == world,+        so turning the part and moving the mark are plain coordinate edits. Refuses instanced parts."""+        frames = self._isolate_frames()+        fents = self._frame_ents(frames)+        rels = []                           # (child rep, parent rep, child frame, parent frame)+        for k in self.order:+            b = self.body[k]+            if self.kind(k) == "" and "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION" in b:+                m = re.search(r"REPRESENTATION_RELATIONSHIP\s*\(\s*'(?:[^']|'')*'\s*,\s*'(?:[^']|'')*'\s*,\s*#(\d+)\s*,\s*#(\d+)", b)+                t = re.search(r"REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION\s*\(\s*#(\d+)", b)+                if m and t and self.kind(int(t.group(1))) == "ITEM_DEFINED_TRANSFORMATION":+                    a1, a2 = self.refs(int(t.group(1)))[:2]+                    rels.append((int(m.group(1)), int(m.group(2)), a1, a2))+        if any(self.kind(k) in ("MAPPED_ITEM", "REPRESENTATION_MAP") for k in self.order):+            raise SystemExit("step_text: MAPPED_ITEM instancing is not supported; pass a flattened STEP")+        parent = {}+        for c, p, a1, a2 in rels:+            if c in parent and parent[c][0] != p:+                raise SystemExit("step_text: an instanced sub-shape (used twice) cannot be flattened as text")+            T = self._mul(self._mat(self._frame(a2)), self._inv(self._mat(self._frame(a1))))+            parent[c] = (p, T)+        reps = [k for k in self.order if (self.kind(k).endswith("REPRESENTATION") and "SHAPE" in self.kind(k)) or self.kind(k) == "SHAPE_REPRESENTATION"]+        reps = [r for r in reps if self.kind(r) not in ("SHAPE_DEFINITION_REPRESENTATION", "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")]+        # SRR-linked reps (shape rep <-> its brep) share the frame of their partner+        srr = {}+        for k in self.order:+            if self.kind(k) == "SHAPE_REPRESENTATION_RELATIONSHIP":+                r = self.refs(k)+                if len(r) >= 2:+                    srr.setdefault(r[1], r[0]); srr.setdefault(r[0], r[1])+        def own_frame(r):+            fs = [x for x in self._rep_items(r) if x in frames]+            return self._mat(self._frame(fs[0])) if fs else None+        def world(r, depth=0):+            if depth > 32:+                raise SystemExit("step_text: assembly loop")+            if r in parent:+                p, T = parent[r]+                return self._mul(world(p, depth + 1), T)+            if r in srr and srr[r] in parent:+                return world(srr[r], depth + 1)+            F = own_frame(r)+            if r in srr and F is None:+                F = own_frame(srr[r])+            # a root frame that no transform uses is applied by OpenCascade as the root placement+            used = set(x for _, _, a1, a2 in rels for x in (a1, a2))+            fs = [x for x in self._rep_items(r) if x in frames]+            if F is not None and not (fs and fs[0] in used):+                return F+            return [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]+        owner = {}+        for r in reps:+            M = world(r)+            geo = self.reach([x for x in self._rep_items(r) if x not in frames]) - fents+            for k in geo:+                if k in owner and owner[k][0] != r and not self._is_identity(owner[k][1]) or (k in owner and owner[k][0] != r and not self._is_identity(M)):+                    if self.kind(k) in ("CARTESIAN_POINT", "DIRECTION"):+                        raise SystemExit("step_text: geometry shared between placed parts; pass a flattened STEP")+                owner.setdefault(k, (r, M))+        for k, (r, M) in owner.items():+            if self._is_identity(M):+                continue+            kd = self.kind(k)+            if kd in ("CARTESIAN_POINT", "DIRECTION"):+                c = self.coords(k)+                if c and len(c) == 3:+                    if kd == "CARTESIAN_POINT":+                        v = [sum(M[i][j] * c[j] for j in range(3)) + M[i][3] for i in range(3)]+                    else:+                        v = [sum(M[i][j] * c[j] for j in range(3)) for i in range(3)]+                    self.set_coords(k, v)+        ident = {0: [0.0, 0.0, 0.0], 1: [0.0, 0.0, 1.0], 2: [1.0, 0.0, 0.0]}+        for f in frames:+            for i, r in enumerate(self.refs(f)[:3]):+                self.set_coords(r, ident[i])+        self._flat = True+        return self++    def rotate90(self, axis, q):+        """Rotate the whole model by q quarter turns (counter-clockwise looking down +axis). The file is+        flattened first (frames at identity), then only geometry turns, so every STEP reader agrees."""+        q %= 4+        if not getattr(self, "_flat", False):+            self.flatten()+        if q == 0:+            return self+        def rot(v):+            x, y, z = v+            for _ in range(q):+                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]+        fents = self._frame_ents(self.rep_frames())+        for k in self.order:+            kd = self.kind(k)+            if kd in ("CARTESIAN_POINT", "DIRECTION") and k not in fents:+                c = self.coords(k)+                if c and len(c) == 3:+                    self.set_coords(k, rot(c))+        return self++    def transform_product(self, name, centre, scale=1.0, quarter_turns_z=0, translate=(0, 0, 0), set_z=None):+        """p' = centre + scale * Rz(q) (p - centre) + translate  for the product's own points; optional+        set_z replaces the z of every 3-D point (a flat mark seated on its face). 2-D (pcurve) points scale+        about the origin, directions turn, VECTOR magnitudes scale."""+        if not getattr(self, "_flat", False):+            self.flatten()+        ents = self.product_entities(name)+        if ents is not None:+            ents -= self._frame_ents(self.rep_frames())+        if not ents:+            raise SystemExit(f"step_text: no product named {name!r}")+        q = quarter_turns_z % 4+        def rz(x, y):+            for _ in range(q):+                x, y = -y, x+            return x, y+        cx, cy, cz = centre+        for k in ents:+            kd = self.kind(k)+            if kd == "CARTESIAN_POINT":+                c = self.coords(k)+                if c is None:+                    continue+                if len(c) == 3:+                    x, y = rz(c[0] - cx, c[1] - cy)+                    z = c[2] - cz+                    nz = (set_z if set_z is not None else cz + scale * z + translate[2])+                    self.set_coords(k, [cx + scale * x + translate[0], cy + scale * y + translate[1], nz])+                elif len(c) == 2:+                    self.set_coords(k, [scale * c[0], scale * c[1]])+            elif kd == "DIRECTION":+                c = self.coords(k)+                if c and len(c) == 3 and q:+                    x, y = rz(c[0], c[1]); self.set_coords(k, [x, y, c[2]])+            elif kd == "VECTOR" and scale != 1.0:+                m = re.match(r"(VECTOR\s*\(\s*'(?:[^']|'')*'\s*,\s*#\d+\s*,\s*)([^)]+)(\)\s*)$", self.body[k], re.S)+                if m:+                    self.body[k] = m.group(1) + fmt(float(m.group(2)) * scale) + m.group(3)+            elif kd in ("CIRCLE", "CYLINDRICAL_SURFACE", "CONICAL_SURFACE", "SPHERICAL_SURFACE", "ELLIPSE", "TOROIDAL_SURFACE") and scale != 1.0:+                raise SystemExit(f"step_text: product {name!r} has a {kd}; scaling it is not supported (flat glyph marks are lines and B-splines)")+        return self++    def faces_subset_entities(self, name, face_ids):+        """Entities that belong to the given faces of a product and to no other face of it."""+        ents = self.product_entities(name) - self._frame_ents(self.rep_frames())+        allf = [k for k in ents if self.kind(k) in ("ADVANCED_FACE", "FACE_SURFACE")]+        mine = self.reach(face_ids) & ents+        others = self.reach([f for f in allf if f not in face_ids]) & ents+        return mine - others++    def transform_entities(self, ents, centre, scale=1.0, translate=(0, 0, 0), set_z=None):+        """Uniform scale about centre + translation for a set of entities (one line of a mark)."""+        cx, cy, cz = centre+        for k in ents:+            kd = self.kind(k)+            if kd == "CARTESIAN_POINT":+                c = self.coords(k)+                if c and len(c) == 3:+                    nz = set_z if set_z is not None else cz + scale * (c[2] - cz) + translate[2]+                    self.set_coords(k, [cx + scale * (c[0] - cx) + translate[0], cy + scale * (c[1] - cy) + translate[1], nz])+                elif c and len(c) == 2:+                    self.set_coords(k, [scale * c[0], scale * c[1]])+            elif kd == "VECTOR" and scale != 1.0:+                m = re.match(r"(VECTOR\s*\(\s*'(?:[^']|'')*'\s*,\s*#\d+\s*,\s*)([^)]+)(\)\s*)$", self.body[k], re.S)+                if m:+                    self.body[k] = m.group(1) + fmt(float(m.group(2)) * scale) + m.group(3)+        return self++    def remove_product(self, name, pids=None):+        """Delete a product (an old laser mark) and everything only it uses: its assembly occurrence,+        placement, representation, geometry and styling. References to it inside lists are dropped."""+        if not getattr(self, "_flat", False):+            self.flatten()+        pids = list(pids) if pids else [p for p, nm in self.products() if nm == name]+        if not pids:+            return self+        self._orig_refd = set(x for k in self.order for x in self.refs(k))+        own = set(self.product_entities(name, pids) or set())+        dead = set(pids)+        for p in pids:+            for r in self.product_reps(p):+                dead.add(r)+        dead |= own+        def top_args(body):+            """Top-level argument strings of SIMPLE(...)."""+            i = body.index("(") + 1; depth = 0; inq = False; cur = ""; args = []+            for c in body[i:]:+                if inq:+                    cur += c+                    if c == "'": inq = False+                    continue+                if c == "'": inq = True; cur += c; continue+                if c == "(":+                    depth += 1+                elif c == ")":+                    if depth == 0:+                        args.append(cur); break+                    depth -= 1+                if c == "," and depth == 0:+                    args.append(cur); cur = ""; continue+                cur += c+            return args+        changed = True+        while changed:+            changed = False+            for k in list(self.order):+                if k in dead:+                    continue+                refs = set(self.refs(k))+                if not refs & dead:+                    continue+                b = self.body[k]+                if self.kind(k) == "":+                    dead.add(k); changed = True; continue+                args = top_args(b)+                direct = [a.strip() for a in args if a.strip().startswith("#")]+                if any(int(a[1:]) in dead for a in direct if a[1:].isdigit()):+                    dead.add(k); changed = True; continue+                # drop dead refs from lists+                nb = b+                for d in refs & dead:+                    nb = re.sub(rf"\s*,\s*#{d}(?!\d)|#{d}(?!\d)\s*,\s*|#{d}(?!\d)", "", nb, count=0)+                if re.search(r"\(\s*\)", nb) and self.kind(k) in ("PRODUCT_RELATED_PRODUCT_CATEGORY", "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION", "PRESENTATION_LAYER_ASSIGNMENT", "DRAUGHTING_MODEL"):+                    if self.kind(k) != "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION":+                        dead.add(k); changed = True; continue+                self.body[k] = nb+        # garbage collection: keep what the file's original roots (entities nobody referenced) still reach+        referenced = set(x for k in self.order for x in self.refs(k)) if not hasattr(self, "_orig_refd") else self._orig_refd+        roots = [k for k in self.order if k not in dead and k not in referenced]+        self._orig_refd = referenced+        live = self.reach(roots, stop=dead)+        self.order = [k for k in self.order if k in live]+        for k in list(self.body):+            if k not in live:+                self.body.pop(k, None)+        self._rev = None+        return self++    # ---- proxy etch support ------------------------------------------------------------------+    def scale_axes(self, x0, y0, z0, sx, sy, sz):+        """Map a unit-box template onto [x0, x0+sx] x [y0, y0+sy] x [z0, z0+sz] (template has no pcurves)."""+        fents = self._frame_ents(self.rep_frames())+        for k in self.order:+            if self.kind(k) == "CARTESIAN_POINT" and k not in fents:+                c = self.coords(k)+                if c and len(c) == 3:+                    self.set_coords(k, [x0 + sx * c[0], y0 + sy * c[1], z0 + sz * c[2]])+        return self++    def _pd_of(self, pid):+        for pdf in self._up(pid, "PRODUCT_DEFINITION_FORMATION") + self._up(pid, "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE"):+            for pd in self._up(pdf, "PRODUCT_DEFINITION"):+                return pd+        return None++    def _root_products(self):+        related = set()+        for k in self.order:+            if self.kind(k) == "NEXT_ASSEMBLY_USAGE_OCCURRENCE":+                related.add(self.refs(k)[1])+        return [(p, n) for p, n in self.products() if self._pd_of(p) not in related]++    @staticmethod+    def _remap(body, off):+        out, i, n, inq = [], 0, len(body), False+        while i < n:+            c = body[i]+            if inq:+                out.append(c)+                if c == "'":+                    if i + 1 < n and body[i + 1] == "'":+                        out.append("'"); i += 2; continue+                    inq = False+                i += 1; continue+            if c == "'":+                inq = True; out.append(c); i += 1; continue+            if c == "#":+                j = i + 1+                while j < n and body[j].isdigit():+                    j += 1+                out.append("#" + str(int(body[i + 1:j]) + off)); i = j; continue+            out.append(c); i += 1+        return "".join(out)++    def graft(self, donor, under="Adom.Chip"):+        """Replace the single child of product `under` with the donor file's whole model (its root+        product and everything below it, colours included). Both files must be flattened."""+        chip = [p for p, n in self.products() if n == under]+        if not chip:+            raise SystemExit(f"step_text: no product {under!r} to graft under")+        chip_pd = self._pd_of(chip[0])+        nauo = [k for k in self.order if self.kind(k) == "NEXT_ASSEMBLY_USAGE_OCCURRENCE" and self.refs(k)[0] == chip_pd]+        if len(nauo) != 1:+            raise SystemExit(f"step_text: {under} has {len(nauo)} children; expected the proxy only")+        nauo = nauo[0]+        old_pd = self.refs(nauo)[1]+        pds = [k for k in self.rev().get(nauo, []) if self.kind(k) == "PRODUCT_DEFINITION_SHAPE"][0]+        cdsr = [k for k in self.rev().get(pds, []) if self.kind(k) == "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION"][0]+        rr = self.refs(cdsr)[0]+        m = re.search(r"REPRESENTATION_RELATIONSHIP\s*\(\s*'(?:[^']|'')*'\s*,\s*'(?:[^']|'')*'\s*,\s*#(\d+)", self.body[rr])+        old_rep = int(m.group(1))+        idt = int(re.search(r"REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION\s*\(\s*#(\d+)", self.body[rr]).group(1))+        old_frame = self.refs(idt)[0]+        old_pid = [p for p, n in self.products() if self._pd_of(p) == old_pd][0]+        # donor root+        roots = donor._root_products()+        if len(roots) != 1:+            raise SystemExit(f"step_text: the model has {len(roots)} root products; expected one")+        r_pid, r_name = roots[0]+        r_pd = donor._pd_of(r_pid)+        r_pds = [k for k in donor.rev().get(r_pd, []) if donor.kind(k) == "PRODUCT_DEFINITION_SHAPE"][0]+        r_rep = [donor.refs(k)[-1] for k in donor.rev().get(r_pds, []) if donor.kind(k) == "SHAPE_DEFINITION_REPRESENTATION"][0]+        fr = [x for x in donor._rep_items(r_rep) if donor.kind(x) == "AXIS2_PLACEMENT_3D"]+        if fr:+            r_frame = fr[0]+        else:                                   # give the root representation a frame to be placed by+            pk = donor._add("CARTESIAN_POINT('',(0.,0.,0.))"); dz = donor._add("DIRECTION('',(0.,0.,1.))"); dx = donor._add("DIRECTION('',(1.,0.,0.))")+            r_frame = donor._add(f"AXIS2_PLACEMENT_3D('',#{pk},#{dz},#{dx})")+            b = donor.body[r_rep]+            i = b.index("(", b.index("(") + 1)+            donor.body[r_rep] = b[: i + 1] + f"#{r_frame}," + b[i + 1:]+        off = max(self.order) + 10+        for k in donor.order:+            nk = k + off+            self.body[nk] = self._remap(donor.body[k], off); self.lead[nk] = ""; self.order.append(nk)+        self._rev = None+        rep = lambda b, a, z: re.sub(rf"#{a}(?!\d)", f"#{z}", b, count=1)+        self.body[nauo] = rep(self.body[nauo], old_pd, r_pd + off)+        self.body[rr] = rep(self.body[rr], old_rep, r_rep + off)+        self.body[idt] = rep(self.body[idt], old_frame, r_frame + off)+        self._rev = None+        self.remove_product(None, [old_pid])+        # the root keeps the model's name+        for p, n in self._root_products():+            if n == "adom_mark_proxy":+                self.body[p] = self.body[p].replace("'adom_mark_proxy'", "'" + r_name.replace("'", "''") + "'")+        self._rev = None+        return self++    def rename_product(self, old, new):+        for k in self.order:+            if self.kind(k) == "PRODUCT":+                self.body[k] = self.body[k].replace(f"'{old}'", f"'{new}'")+        return self++    @property+    def text(self):+        parts = [self.head, "\n"]+        for k in self.order:+            parts.append(f"#{k} = {self.body[k]};\n")+        parts.append(self.tail)+        return "".join(parts)++    def write(self, path):+        open(path, "w", encoding="latin-1").write(self.text)+        return path+++def bbox(points):+    xs, ys, zs = zip(*points)+    return [min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)]+++if __name__ == "__main__":+    a = sys.argv[1:]+    if a and a[0] == "rotate":+        Step(a[1]).rotate90(a[3], int(a[4])).write(a[2])+    elif a and a[0] == "products":+        s = Step(a[1])+        for k, n in s.products():+            print(k, n)+    else:+        print(__doc__); sys.exit(2)
planner/test_mark_gate.pyadded+64
@@ -0,0 +1,64 @@+#!/usr/bin/env python3+"""Prove the legibility gate catches the bad marks AIs kept making, from one good etch:+  across     etched with --along across the short side       -> FAIL axis ("Text runs across ...")+  upside     the good mark turned 180 deg in place             -> FAIL upright ("flip 180")+  small      the good mark at 0.55 x                           -> FAIL size ("increase to ~...")+  raised     the good mark lifted 0.1 mm off the face          -> FAIL flat+  outline    an outline whose name runs across the short side  -> FAIL outline (with --bad-outline)+Usage: test_mark_gate.py --step part.step --footprint fp.kicad_mod --mpn MPN --out-dir DIR [--good good-etched.step]+       [--bad-outline bad.svg]   (needs the shared service; writes DIR/*.check.json|png and DIR/results.json)"""+import argparse, json, os, shutil, sys++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+import step_text as ST+import mark_etch as ME+import mark_check as MC+import mark_geom as MG+++def variant(good, out, fn):+    s = ST.Step(good); s.flatten()+    a = MG.analyse(s, "Adom.LaserEtch")+    fn(s, a); s.write(out)+++def main():+    ap = argparse.ArgumentParser(); ap.add_argument("--step", required=True); ap.add_argument("--footprint", required=True)+    ap.add_argument("--mpn", required=True); ap.add_argument("--out-dir", required=True); ap.add_argument("--good"); ap.add_argument("--bad-outline")+    a = ap.parse_args()+    os.makedirs(a.out_dir, exist_ok=True)+    d = lambda n: os.path.join(a.out_dir, f"{a.mpn}-{n}.step")+    good = a.good or d("good")+    if not a.good:+        ME.etch(a.step, a.mpn, good, a.footprint)+    plan = ME.MP.plan(a.step, a.footprint, a.mpn)+    cases = {}+    # across the short side: the override the old tools defaulted to+    long_axis = plan["longAxis"]["axis"]+    ME.etch(a.step, a.mpn, d("across"), a.footprint, along="x" if long_axis == "y" else "y")+    cases["across"] = d("across")+    variant(good, d("upside"), lambda s, m: s.transform_product("Adom.LaserEtch", m["centre"], 1.0, 2))+    variant(good, d("small"), lambda s, m: s.transform_product("Adom.LaserEtch", m["centre"], 0.55, 0))+    variant(good, d("raised"), lambda s, m: s.transform_product("Adom.LaserEtch", m["centre"], 1.0, 0, (0, 0, 0.1)))+    cases.update(upside=d("upside"), small=d("small"), raised=d("raised"))+    results = {}+    rep = MC.check(good, plan=plan, footprint=a.footprint, quiet=True)+    results["good"] = dict(status=rep["status"], checks={c["check"]: [c["status"], c["detail"]] for c in rep["checks"]}, image=rep.get("image"))+    for k, p in cases.items():+        rep = MC.check(p, plan=plan, footprint=a.footprint, quiet=True)+        results[k] = dict(status=rep["status"], fails=[f"{c['status']} {c['check']}: {c['detail']}" for c in rep["checks"] if c["status"] != "PASS"], image=rep.get("image"))+    if a.bad_outline:+        rep = MC.check(good, plan=plan, footprint=a.footprint, outline=a.bad_outline, quiet=True, render=False)+        results["outline"] = dict(status=rep["status"], fails=[f"{c['status']} {c['check']}: {c['detail']}" for c in rep["checks"] if c["status"] != "PASS"], file=a.bad_outline)+    json.dump(results, open(os.path.join(a.out_dir, "results.json"), "w"), indent=1)+    ok = results["good"]["status"] == "PASS" and all(results[k]["status"] == "FAIL" for k in results if k != "good")+    for k, r in results.items():+        print(f"{k:8s} {r['status']}")+        for f in r.get("fails", []):+            print("         Hint: " + f)+    print("gate test:", "OK (good passes, every bad case fails)" if ok else "PROBLEM")+    return 0 if ok else 1+++if __name__ == "__main__":+    sys.exit(main())
src/main.rs+1
@@ -2,6 +2,7 @@ mod cli; mod install; mod library; mod manifest;+mod mark; mod queue; mod render_3d; mod serve;
src/mark.rsadded+179
@@ -0,0 +1,179 @@+//! The part-number mark, placed by construction: the named outlines and the name-lasered icon come+//! from the shared MARK PLANNER (adom-aiflow's `tools/laser-etch`, vendored here in `planner/` and+//! installed to ~/.local/share/adom-chip-thumbnailer/planner). The same code as `adom-aiflow-etch`, so+//! the thumbnailer and the etch can never disagree:+//!+//!   * the mark runs along the longest usable dimension (chip R/C/L between the end caps, ICs along the+//!     pin rows, LEDs on the front side wall), as large as fits, one line or two equal lines;+//!   * the outline pose is the standard iso pose: pin 1 (pins 1 to 3) toward the viewer, the name upright+//!     and left to right; the outline `rot` is the planner's, never a fixed 180 / 90;+//!   * every named SVG is gated; a failing one is written as `*.FAILED-GATE.svg` with a loud warning and+//!     Hint lines, never silently as `-named`.+//!+//! Plain python3 only (stdlib); the geometry runs on the shared service-step2glb.++use crate::library::ChipPaths;+use anyhow::{anyhow, Context, Result};+use std::path::{Path, PathBuf};+use std::process::Command;++pub struct MarkOutcome {+    pub status: String,+    pub plan: Option<PathBuf>,+    pub posed: Option<PathBuf>,+    pub etched: Option<PathBuf>,+    pub outlines: Vec<(PathBuf, String)>,+    pub summary: String,+    pub hints: Vec<String>,+}++/// Where the planner's Python lives: $ADOM_MARK_PLANNER, this package's installed copy, the binary's+/// sibling `planner/`, or an installed adom-aiflow (`adom-aiflow-etch` sits next to the same files).+pub fn planner_dir() -> Option<PathBuf> {+    let ok = |p: &Path| p.join("mark_outline.py").is_file() && p.join("mark_etch.py").is_file();+    if let Ok(p) = std::env::var("ADOM_MARK_PLANNER") {+        let p = PathBuf::from(p);+        if ok(&p) {+            return Some(p);+        }+    }+    let home = std::env::var("HOME").unwrap_or_default();+    let mut cands = vec![PathBuf::from(&home).join(".local/share/adom-chip-thumbnailer/planner")];+    if let Ok(exe) = std::env::current_exe() {+        if let Some(d) = exe.parent() {+            cands.push(d.join("planner"));+            cands.push(d.join("../planner"));+        }+    }+    if let Ok(t) = std::fs::canonicalize(PathBuf::from(&home).join(".local/bin/adom-aiflow-etch")) {+        if let Some(d) = t.parent() {+            cands.push(d.to_path_buf());+        }+    }+    cands.into_iter().find(|p| ok(p))+}++fn last_json(stdout: &str) -> Option<serde_json::Value> {+    stdout.lines().rev().find(|l| l.trim_start().starts_with('{')).and_then(|l| serde_json::from_str(l).ok())+}++fn run_py(dir: &Path, script: &str, args: &[String]) -> Result<(bool, String, String)> {+    let out = Command::new(std::env::var("AIFLOW_PYTHON").unwrap_or_else(|_| "python3".into()))+        .arg(dir.join(script))+        .args(args)+        .env("AIFLOW_ETCH_CLIENT", "chip-thumbnailer/chip-fetcher")+        .output()+        .with_context(|| format!("run python3 {script} (is python3 installed?)"))?;+    Ok((+        out.status.success(),+        String::from_utf8_lossy(&out.stdout).to_string(),+        String::from_utf8_lossy(&out.stderr).to_string(),+    ))+}++fn hints_of(stderr: &str) -> Vec<String> {+    stderr.lines().filter(|l| l.starts_with("Hint:") || l.starts_with("ERROR")).map(str::to_string).collect()+}++/// Etch (if needed) and draw the named outlines for one chip; writes into the chip folder:+///   <MPN>-etched.step                          the model with its flat Adom.LaserEtch mark+///   <MPN>-3d-outline-<style>.svg               plain outlines, standard pose+///   <MPN>-3d-outline-<style>-named.svg         the name = the mark (gate PASS/WARN)+///   <MPN>-3d-outline-<style>-named.FAILED-GATE.svg   only when the gate failed (loud warning)+///   <MPN>.mark-plan.json                       the planner's JSON (schema adom/mark-plan@1)+pub fn named_family(chip: &ChipPaths, up: Option<&str>) -> Result<MarkOutcome> {+    let dir = planner_dir().ok_or_else(|| {+        anyhow!("the mark planner is not installed (reinstall adom-chip-thumbnailer, or install adom-aiflow; or set ADOM_MARK_PLANNER)")+    })?;+    let step = chip.step.as_ref().ok_or_else(|| anyhow!("no .step for {}", chip.mpn))?;+    let mut hints = Vec::new();+    let mut common: Vec<String> = Vec::new();+    if let Some(fp) = &chip.kicad_mod {+        common.push("--footprint".into());+        common.push(fp.display().to_string());+    } else {+        hints.push("Hint: WARNING no <MPN>.kicad_mod beside the STEP: pin 1 is assumed at the negative end; add the footprint for a certain reading direction".into());+    }+    if let Some(u) = up {+        common.push("--up".into());+        common.push(u.into());+    }+    let etched = chip.dir.join(format!("{}-etched.step", chip.mpn));+    let mut have_etched = etched.is_file();+    if !have_etched {+        let mut a = vec!["--in".into(), step.display().to_string(), "--mpn".into(), chip.mpn.clone(), "--out".into(), etched.display().to_string()];+        a.extend(common.iter().cloned());+        let (ok, _so, se) = run_py(&dir, "mark_etch.py", &a)?;+        hints.extend(hints_of(&se));+        have_etched = ok && etched.is_file();+        if !have_etched {+            hints.push("Hint: no etched STEP (see the lines above); the named outline is drawn from the plain model on the planned band".into());+        }+    }+    let posed = chip.dir.join(format!(".posed-{}.step", chip.mpn));+    let mut a = vec![+        step.display().to_string(),+        "--mpn".into(),+        chip.mpn.clone(),+        "--out-dir".into(),+        chip.dir.display().to_string(),+        "--styles".into(),+        "all".into(),+        "--stem".into(),+        chip.mpn.clone(),+        "--write-plain".into(),+        "--posed-out".into(),+        posed.display().to_string(),+    ];+    a.extend(common.iter().cloned());+    if have_etched {+        a.push("--etched".into());+        a.push(etched.display().to_string());+    }+    let (_ok, so, se) = run_py(&dir, "mark_outline.py", &a)?;+    hints.extend(hints_of(&se));+    let v = last_json(&so).ok_or_else(|| anyhow!("mark_outline.py gave no result: {}", se.lines().last().unwrap_or("")))?;+    let outlines = v["outlines"]+        .as_array()+        .map(|a| {+            a.iter()+                .map(|o| (PathBuf::from(o["path"].as_str().unwrap_or("")), o["status"].as_str().unwrap_or("?").to_string()))+                .collect()+        })+        .unwrap_or_default();+    let plan = v["plan"].as_str().map(PathBuf::from);+    let summary = plan+        .as_ref()+        .and_then(|p| std::fs::read_to_string(p).ok())+        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())+        .and_then(|j| j["summary"].as_str().map(str::to_string))+        .unwrap_or_default();+    Ok(MarkOutcome {+        status: v["status"].as_str().unwrap_or("FAIL").to_string(),+        plan,+        posed: if posed.is_file() { Some(posed) } else { None },+        etched: if have_etched { Some(etched) } else { None },+        outlines,+        summary,+        hints,+    })+}++/// Remove name variants an older thumbnailer wrote with fixed rotations (named90, named180, named+/// outlines from a previous run), so a stale wrong-way name can never be picked up.+pub fn clear_stale_named(chip: &ChipPaths) {+    if let Ok(rd) = std::fs::read_dir(&chip.dir) {+        for e in rd.flatten() {+            let n = e.file_name().to_string_lossy().to_string();+            if !n.starts_with(&chip.mpn) {+                continue;+            }+            let stale = (n.contains("-3d-outline-") && (n.contains("-named") || n.contains("named90") || n.contains("named180")))+                || n.contains("-3d-iso-named90")+                || n.ends_with("-3d-iso-named-debug.png");+            if stale {+                let _ = std::fs::remove_file(e.path());+            }+        }+    }+}
src/render_3d.rs+60−15
@@ -184,23 +184,53 @@ fn facing_rotations(orient: ThreeDOrientation) -> (u32, u32) { ///   <MPN>-3d-outline-<style>-named.svg    (5 name-marked styles) pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {     let step_bytes = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;-    let orient = chosen_orientation(chip);+    let (orient, source) = up_axis_decision(chip);     let axis = orient.step2glb_arg(); // "asIs" / "yUp" / ... -    // The text rotation that faces the viewer depends on the up-axis (the fixed-    // 180 pointed away on y-up chips). `primary` = the default `-named`;-    // `alt` = the right-angle `-named90` for long names that would spill.-    let (primary, alt) = facing_rotations(orient);--    // Plain vector outlines (no text, so rotation is irrelevant).-    write_outlines(chip, &step_bytes, axis, None, "", primary)?;-    if write_named(chip, &step_bytes, axis, primary, "named").is_ok() {-        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named", primary);-        // A debug tile showing the full + top-slice bboxes used for placement.-        let _ = write_named_debug(chip, &step_bytes, axis, primary);-    }-    if write_named(chip, &step_bytes, axis, alt, "named90").is_ok() {-        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named90", alt);+    // The part number is the MARK PLANNER's (crate::mark): along the long axis, upright with pin 1+    // toward the viewer, the same mark as the etched model, gated. No fixed 180 / 90 variants any more:+    // those were exactly how names ended up across the short side or upside down.+    crate::mark::clear_stale_named(chip);+    let up = match (orient, source) {+        (ThreeDOrientation::YUp, "override") => Some("y"),+        (ThreeDOrientation::AsIs, "override") => Some("z"),+        _ => None,+    };+    match crate::mark::named_family(chip, up) {+        Ok(m) => {+            if !m.summary.is_empty() {+                eprintln!("Hint: {}", m.summary);+            }+            for h in &m.hints {+                eprintln!("{h}");+            }+            let failed: Vec<_> = m.outlines.iter().filter(|(_, s)| s == "FAIL").collect();+            if !failed.is_empty() || m.status == "FAIL" {+                eprintln!(+                    "WARNING: {} named outline(s) for {} FAILED the legibility gate and were written as *.FAILED-GATE.svg, not as -named.svg",+                    failed.len(),+                    chip.mpn+                );+                eprintln!("Hint: read the Hint lines above, fix the plan (footprint, --up), rerun; never rename a FAILED-GATE file to -named");+            } else {+                eprintln!(+                    "Hint: {} named outlines PASS the gate (plan: {}); look at one at 320 px before you report",+                    m.outlines.len(),+                    m.plan.as_ref().map(|p| p.display().to_string()).unwrap_or_default()+                );+            }+            // the name-lasered iso: the posed (etched) model itself, so the shaded icon shows the real mark+            if let Some(posed) = &m.posed {+                let _ = write_named_posed(chip, posed, "named");+                let _ = std::fs::remove_file(posed);+            }+        }+        Err(e) => {+            eprintln!("WARNING: no named outlines for {}: {e:#}", chip.mpn);+            eprintln!("Hint: the name is only drawn where the mark planner placed it; install python3 and reinstall adom-chip-thumbnailer (it ships the planner), then rerender");+            // plain outlines still render in the as-is pose (no text, so rotation is irrelevant)+            write_outlines(chip, &step_bytes, axis, None, "", 0)?;+        }     }      // The canonical `<MPN>-3d-iso-icon.png` (no axis suffix) follows the chosen@@ -216,6 +246,21 @@ pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {     Ok(()) } +/// Name-lasered iso + transparent icon from the planner's posed STEP (the etched model in the standard+/// pose): no name drawn by the renderer, the mark is in the model. Writes <MPN>-3d-iso-<tag>[-icon].png.+fn write_named_posed(chip: &ChipPaths, posed: &Path, tag: &str) -> Result<()> {+    let green = chip.dir.join(format!(".green-{tag}-{}.png", chip.mpn));+    service_thumbnail_named_ext(posed, &green, "asIs", 320, 240, &chip.mpn, "00ff00", "", 0, false)?;+    if let Ok((w, h, rgba)) = key_green_rgba(&green) {+        let icon = chip.dir.join(format!("{}-3d-iso-{tag}-icon.png", chip.mpn));+        let _ = write_rgba_png(&icon, w, h, &rgba);+        let disp = chip.dir.join(format!("{}-3d-iso-{tag}.png", chip.mpn));+        let _ = write_rgba_on_dark(&disp, w, h, &rgba);+    }+    let _ = std::fs::remove_file(&green);+    Ok(())+}+ /// Render a placement-debug tile: the chip on the dark canvas with the full /// bbox (red) + top-slice bbox (cyan) overlaid, so the dashboard can explain how /// the part-number placement was derived. Writes <MPN>-3d-iso-named-debug.png.
uninstall.sh+1−1
@@ -1,2 +1,2 @@ #!/bin/bash-rm -f "${HOME}/.local/bin/adom-chip-thumbnailer"; echo "Removed adom-chip-thumbnailer"+rm -f "${HOME}/.local/bin/adom-chip-thumbnailer"; rm -rf "${HOME}/.local/share/adom-chip-thumbnailer/planner"; echo "Removed adom-chip-thumbnailer"