app
Adom Chip Thumbnailer
Public Made by Adomby adom
The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app + CLI.
← Commit history
1.2.0: part number placed by the mark planner (long axis, upright with pin 1 toward the viewer, same mark as the etched model), every named outline gated
23 files changed
+2578−65
Cargo.lock+1−1Cargo.toml+1−1README.md+18−29SKILL.md+34−13VERSION+1−1bin/adom-chip-thumbnailerinstall.sh+10−2package.json+1−1page.json+1−1planner/SOURCE.json+16planner/mark_check.py+363planner/mark_etch.py+162planner/mark_geom.py+114planner/mark_outline.py+152planner/mark_plan.py+515planner/mark_svc.py+68planner/proxy_box.step+187planner/step_text.py+629planner/test_mark_gate.py+64src/main.rs+1src/mark.rs+179src/render_3d.rs+60−15uninstall.sh+1−1Cargo.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: ---## 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. -+ ## 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:  - ## 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"+ }+}
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"