← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.47"+version = "0.1.48" dependencies = [  "serde",  "serde_json",@@ -33,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.47"+version = "0.1.48" dependencies = [  "serde",  "serde_json",@@ -41,7 +41,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-board",  "serde",@@ -50,7 +50,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -60,7 +60,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-board",  "serde",@@ -69,7 +69,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-board",  "serde",@@ -78,7 +78,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -88,7 +88,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.47"+version = "0.1.48" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -98,7 +98,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.47"+version = "0.1.48" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.47"+version = "0.1.48" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+9−1
@@ -143,7 +143,15 @@ Before placement, declare `step components` and run `components` with your `--ru  Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project. -MPN marking is on by default for ICs: every IC's model carries its laser-etched MPN and `finish` checks it (`components --etch off` declines for a run). Mark with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model.step>`: it uses adom/adom-chip-laser but keeps the model's own colours (the service alone repaints the part black and grey), lays the text along the part's longest top-face direction, stands Y-up vendor models upright first (`--up y`), and writes a FLAT mark (each glyph's top face only, about a fifth of the triangles of the service's extruded glyphs). Until the service does these itself (adom/adom-chip-laser#2) the tool needs OpenCascade locally: it installs the lightweight `cadquery-ocp-novtk` build (about 0.4 GB; the full `cadquery-ocp` with VTK is about 1.3 GB and VTK is never needed to process STEP files). Ask the user, then run it with `AIFLOW_ALLOW_LOCAL_OCC=1`. Shared services come first; the local build is the fallback when they are busy or cannot do the job. Use `--mark dark` on light bodies. Component-page heroes add the footprint art per eda-component-hero (see aiflow-sourcing). Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.+MPN marking is on by default for ICs: every IC's model carries its laser-etched MPN and `finish` checks it (`components --etch off` declines for a run). `adom-aiflow-etch` places the mark by construction (the mark planner), writes it on the shared STEP service (colours kept, `Adom.Chip` + `Adom.LaserEtch`, flat) and gates it. Component-page heroes add the footprint art per eda-component-hero (see aiflow-sourcing). Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence (the etch writes `<out>.mark-plan.json` and `<out>.check.json`). Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++### Marking checklist (laser-etched MPN)++1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot.+2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and 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); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun.+5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner. A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path).  ## Provenance is required for shared component assets 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+21−4
@@ -732,6 +732,12 @@ fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) {     (pulled, missing) } +/// True when a STEP carries a laser-etched MPN part: `Adom.LaserEtch` (adom-aiflow-etch 0.1.48+, the+/// shared service) or the legacy `laser_etch` part of earlier releases.+fn has_mpn_mark(bytes: &[u8]) -> bool {+    bytes.windows(14).any(|w| w == b"Adom.LaserEtch") || bytes.windows(10).any(|w| w == b"laser_etch")+}+ /// IC footprints (U*, IC*) whose bound 3D model is a local STEP without a laser-etched MPN part. /// Models are found beside the source board (${KIPRJMOD}); an unresolvable path is left to `models`. fn unmarked_ics(r: &Run) -> Vec<String> {@@ -746,7 +752,7 @@ fn unmarked_ics(r: &Run) -> Vec<String> {             let path = m.replace("${KIPRJMOD}", &proj.display().to_string());             if !(path.to_ascii_lowercase().ends_with(".step") || path.to_ascii_lowercase().ends_with(".stp")) { continue; }             match std::fs::read(&path) {-                Ok(bytes) => { if !bytes.windows(10).any(|w| w == b"laser_etch") { out.push(reference.clone()); } }+                Ok(bytes) => { if !has_mpn_mark(&bytes) { out.push(reference.clone()); } }                 Err(_) => {}             }         }@@ -2198,7 +2204,7 @@ fn main() {             let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();             let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();             let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();-            hints.push(format!("MPN marking preference: {preference} (default on: every IC's model carries its laser-etched MPN, and finish checks it). Mark a model with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model.step>`: it keeps the model's colours and lays the text along the long axis. 'off' keeps plain models; 'ask' offers the option. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));+            hints.push(format!("MPN marking preference: {preference} (default on: every IC's model carries its laser-etched MPN, and finish checks it). Mark a model with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>`: the mark planner places it (along the longest usable dimension, between the terminations, as large as fits, upright with pin 1 lower left), the shared service writes it with the model's colours, and the legibility gate must say PASS; look at <etched>.check.png before you report (Marking checklist in the adom-aiflow skill). 'off' keeps plain models; 'ask' offers the option. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));             ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);         }         Cmd::Plan => {@@ -2838,12 +2844,12 @@ fn main() {                 if !have { missing.push(format!("{k} evidence (evidence add --kind {k} --file <json|md>)")); }             }             // MPN marking: unless it was turned off, every IC (U*, IC*) carries its laser-etched MPN on the-            // top face of its model (a 'laser_etch' part in the STEP), so the board and the component+            // marking face of its model (an 'Adom.LaserEtch' or legacy 'laser_etch' part in the STEP), so the board and the component             // pages show which chip is which             let marking = r.data["components"]["mpnMarking"].as_str().unwrap_or("on").to_string();             if marking != "off" {                 let unmarked = unmarked_ics(&r);-                if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model.step>`, or `components --etch off` to decline", unmarked.join(", "))); }+                if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>` (gate must PASS), or `components --etch off` to decline", unmarked.join(", "))); }             }             if !missing.is_empty() {                 err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);@@ -3639,6 +3645,17 @@ fn measurement_usable(value: Option<&Value>) -> bool {     value.map(|v| v["failed"] != true && v["filledAreaByLayerMm2"].as_object().map(|a| !a.is_empty() && a.values().all(|n| n.as_f64().map(|x| x.is_finite() && x >= 0.0).unwrap_or(false))).unwrap_or(false)).unwrap_or(false) } +#[cfg(test)]+mod mpn_mark_tests {+    use super::*;+    #[test]+    fn recognises_service_and_legacy_marks() {+        assert!(has_mpn_mark(b"#405 = PRODUCT('Adom.LaserEtch','Adom.LaserEtch','',(#406));"));+        assert!(has_mpn_mark(b"#3258 = PRODUCT('laser_etch','laser_etch','',(#1));"));+        assert!(!has_mpn_mark(b"#7 = PRODUCT('SOT-23-6','SOT-23-6','',(#8));"));+    }+}+ #[cfg(test)] mod measurement_gate_tests {     use super::*;
docs/release-0.1.48.mdadded+10
@@ -0,0 +1,10 @@+# AI Flow 0.1.48++From John's rule (2026-10-03): a part-number laser mark runs along the longest usable dimension of the marking face, as large as fits, upright and left to right with pin 1 lower left, flat and light, and the chip outline's name is the same mark. The tools now get it right by construction and check it.++- **Mark planner** (`adom-aiflow-etch plan`, `tools/laser-etch/mark_plan.py`): from the STEP (and the footprint for pin 1, pads and the model rotation) it picks the face (top, or the front side wall for LEDs), the long axis (chip R/C/L along the body between the end caps, ICs along the pin rows), the usable band (end caps and the pin-1 dot excluded), the text size and lines (one line, two equal lines only when both are larger), the reading direction from pin 1, and the outline pose and rot. JSON schema `adom/mark-plan@1`. A vendor model that already prints its marking is detected and left alone.+- **Etch by plan, on the shared service only.** `adom-aiflow-etch --in --mpn --out [--footprint]` no longer needs local OpenCascade: exact quarter turns on the STEP text, the service etches a proxy plate laid on the band, the real model is grafted under `Adom.Chip` next to `Adom.LaserEtch` with its colours, old marks are replaced. `--along` stays as an override and warns. `--legacy` keeps the old OpenCascade path.+- **Legibility gate** (`adom-aiflow-etch check`, runs after every etch): axis, upright, size against what fits, inside the band, flat and coplanar, group name, colour, caps in pixels at 320 px, and (with `--outline`) the outline's name against the model's mark. PASS / WARN / FAIL with Hint lines and a top view in the standard pose (`<etched>.check.png`). `adom-aiflow-etch test` proves it fails the bad cases.+- **Named outline = the mark** (`adom-aiflow-etch outline`): the service's single-stroke name drawn on the planned band and mapped exactly into the standard iso pose; failing SVGs are written as `*.FAILED-GATE.svg`, never silently.+- `finish` recognises `Adom.LaserEtch` as well as the legacy `laser_etch`. Skills (adom-aiflow, aiflow-molecule, aiflow-sourcing) and the board flow carry the Marking checklist.+- Tested on TPS54202DDCR, CR0402-FX-1002GLF, CL21A226MPQNNNE, AMPLH5030S-150MT (vendor marking kept) and IN-S63BTG (side wall).
flows/board.json+2−2
@@ -28,10 +28,10 @@         "Use portable project/library model paths, preserve placement and routing, rerun kicad_model_check and inspect the saved board in native KiCad 3D. Report reference coverage, reused/created page URLs, unresolved identities and missing CAD separately. A successful download or wiki publication alone does not close a missing-model finding.",         "Judge quality per component, not per board screenshot: publish a linked visual register with top, bottom and oblique views. Check dimensions, units, terminal count and pitch, pin-1/polarity, body/pad alignment, standoff, materials and visible details. File resolution alone is not quality. Record pass, needs-work, reference-only or unknown with evidence and limits.",         "Prefer manufacturer CAD when the source permits the intended redistribution. Keep third-party/Ultra Librarian downloads as private reference-only inputs unless redistribution is explicitly permitted. Compare independently generated models against manufacturer drawings and permitted reference views; retain dimensional deviations and source hashes. Converting, extracting, recoloring, or etching a restricted model does not make it independently authored or license-cleared.",-        "MPN marking is ON by default for ICs (John, 2026-09-30): every IC's 3D model carries its MPN laser-etched on the top face, and finish checks it; passives are optional. Run `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model.step>` (adom/adom-chip-laser service plus a colour-preserving merge; the mark is flat, top faces only). It needs OpenCascade locally, the lightweight cadquery-ocp-novtk build (about 0.4 GB, no VTK), until the service keeps colours, turns the part and writes flat marks itself: ask the user before `AIFLOW_ALLOW_LOCAL_OCC=1`. Do not post a STEP to the service and use its output directly: the service repaints the whole part black and grey (adom/adom-chip-laser#2). Keep the plain model beside the marked one; `components --etch off` declines for a run. Marking is an identification aid, not the part's factory top mark.",+        "MPN marking is ON by default for ICs (John, 2026-09-30): every IC's 3D model carries its MPN laser-etched and finish checks it; passives are optional. Run `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>`: the mark planner places it (face, long axis, band between the terminations, size, reading direction from pin 1), the shared STEP service writes it (Adom.Chip + Adom.LaserEtch, flat, colours kept; nothing to install, no local OpenCascade), and the legibility gate checks it. Keep the plain model beside the marked one; `components --etch off` declines for a run. Marking is an identification aid, not the part's factory top mark.",         "Cache reviewed artifacts on the existing global component page: source/plain STEP, optional MPN STEP, GLB, thumbnails and machine-readable provenance. Record input hash, generator/tool version, parameters, units, transforms, marking text/mode, reference evidence and review results. Cache keys must change when geometry, text or generator parameters change. Keep board-only transforms and mappings in the board project. Never claim cross-EDA parity without rendering the variant in the named native viewers.",         "When publishing or improving a component page, make per-file provenance mandatory even though creating a new page is optional. Record original source URL/file and revision, retrieval date, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator/version and parameters, units/transforms, redistribution evidence and limitations. For AI-created geometry cite the actual datasheet page/figure/table and dimensions used, list simplifications and reference comparisons, and never present copied/extracted CAD as independent work. State which checks ran and which remain unverified; retain plain and marked variant lineage. Put a readable provenance section on the page plus a machine-readable asset record and a link to that component's issue tracker. Unknown provenance stays unknown, not a fabricated source. Reuse/improve existing pages first; offer new global-page publication only for reusable components, keeping board-specific records in the project.",-        "Fit the mark along the LONGEST usable top-face direction at the largest legible size (a SOT-23-6 gets 0.22 mm along its long axis against 0.14 mm across it); the service only writes across X, so turn the part first (adom-aiflow-etch does). Stand Y-up vendor models upright first (`--up y`), or the mark lands on a side face. Use a light mark on dark epoxy and a dark mark on light bodies (`--mark dark`). Look at the result: colours intact, text inside the top face, clear of pin-1 and polarity features; a mark across a polarity stripe is worse than none, so turn it onto the clear field with `--along x|y` (an electrolytic's text runs parallel to its stripe, on the silver side) and leave the part unmarked only when no field fits. Every part with an MPN gets the mark, passives included (MLCCs, chip resistors: the tool seats it on the ceramic between the end caps). LEDs keep their window clear: `--face side` puts the MPN on a long side face of the lens/body (`--side-band low` when a see-through window runs the full length, e.g. WS2812B-2020), and the LED model carries its lens colour from the datasheet's dominant wavelength, softened (the Adom basic-part LED pages ship that coloured STEP as the default). If the vendor model already carries a printed marking (contrasting top-face colours spelling a part number, like Abracon's AMPLH5030S), keep the vendor model and do not etch over it. Swapping a model into the board: the board file must point at the etched STEP (check `(model ...)` for every marked ref, including parts that used a KiCad stock model), then show it with `kicad-cli pcb render` before and after, since the desktop 3D viewer cannot be filmed while another window covers it.",+        "Marking checklist (laser-etched MPN): (1) let the planner place it: `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan ...` shows the plan first); never hand-pick an axis or a rotation. (2) The rule it enforces: 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: 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 in the standard pose (pin 1 / pad 1 lower left seen from the top; the iso outline shows pin 1 toward the viewer), a flat light mark coplanar with the face in Adom.Chip / Adom.LaserEtch, never raised or recessed lettering. (3) The chip outline's name is the same mark: `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same), never a hand-picked /outline rot. (4) Run the gate and look: `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS; open <etched>.check.png and the outline at 320 px before you report; a FAIL's Hint lines say what to rerun. (5) `--along` is only for keeping a polarity stripe or printed band clear (the tool warns, the gate still judges against the planner); a vendor model that already prints its marking stays as it is. Every part with an MPN gets the mark, passives included. LED models carry their lens colour from the datasheet's dominant wavelength, softened (the Adom basic-part LED pages ship that coloured STEP as the default). Swapping a model into the board: the board file must point at the etched STEP (check `(model ...)` for every marked ref, including parts that used a KiCad stock model), then show it with `kicad-cli pcb render` before and after, since the desktop 3D viewer cannot be filmed while another window covers it.",         "Offer the optional Hydrogen progress widget. If enabled, reuse saved component, marked-model, symbol and later board/analysis thumbnails via widget event; do not generate extra screenshots or call a model solely for the widget. Respect widget disable immediately.",         "Before accepting a wiki component, read its actual native model_3d and STEP bindings and hash the referenced downloads against the reviewed variants. Wait for the native viewer to finish painting, then inspect the visible body and readable mark; nonzero meshes or a successful HTTP request do not prove a painted model. Check the static hero too. Use a descriptive manufacturer/MPN/function title in both page.json and package.json; follow wiki-component and preserve other contributors and original files.",         "A resolved 3D file may still be hidden by native EDA appearance filters (for example KiCad excludes models not in position files). Check those filters before inventing replacement parts or changing BOM/placement flags. Keep bridge-specific visibility, binding and native audit operations in the owning bridge; report missing capabilities there. Explicit rights-holder permission may authorize a derivative despite a restrictive package default; record who authorized what and its scope, without extending it to third-party assets.",
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.47",+  "version": "0.1.48",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.47",+  "version": "0.1.48",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+9−1
@@ -143,7 +143,15 @@ Before placement, declare `step components` and run `components` with your `--ru  Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project. -MPN marking is on by default for ICs: every IC's model carries its laser-etched MPN and `finish` checks it (`components --etch off` declines for a run). Mark with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model.step>`: it uses adom/adom-chip-laser but keeps the model's own colours (the service alone repaints the part black and grey), lays the text along the part's longest top-face direction, stands Y-up vendor models upright first (`--up y`), and writes a FLAT mark (each glyph's top face only, about a fifth of the triangles of the service's extruded glyphs). Until the service does these itself (adom/adom-chip-laser#2) the tool needs OpenCascade locally: it installs the lightweight `cadquery-ocp-novtk` build (about 0.4 GB; the full `cadquery-ocp` with VTK is about 1.3 GB and VTK is never needed to process STEP files). Ask the user, then run it with `AIFLOW_ALLOW_LOCAL_OCC=1`. Shared services come first; the local build is the fallback when they are busy or cannot do the job. Use `--mark dark` on light bodies. Component-page heroes add the footprint art per eda-component-hero (see aiflow-sourcing). Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.+MPN marking is on by default for ICs: every IC's model carries its laser-etched MPN and `finish` checks it (`components --etch off` declines for a run). `adom-aiflow-etch` places the mark by construction (the mark planner), writes it on the shared STEP service (colours kept, `Adom.Chip` + `Adom.LaserEtch`, flat) and gates it. Component-page heroes add the footprint art per eda-component-hero (see aiflow-sourcing). Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence (the etch writes `<out>.mark-plan.json` and `<out>.check.json`). Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++### Marking checklist (laser-etched MPN)++1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot.+2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and 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); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun.+5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner. A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path).  ## Provenance is required for shared component assets 
skills/aiflow-molecule/SKILL.md+9−1
@@ -105,4 +105,12 @@ A locked interface, a rules profile in the board, and after the build a converte  ## Component wiki pages: the hero is annotated and etched -Whenever a component page is created or its model changes (here, or through molecule-publish's "create missing component pages" stage), the page's hero GLB follows `eda-component-hero` (adom/eda-engineering): the part with its laser-etched MPN (`adom-aiflow-etch`, colours kept, along the long axis), teal dashed copper-pad outlines from the real pads at alpha 0.50, the footprint silkscreen at 0.30, and pin labels at 0.50, as separate nonphysical nodes in the verified Z-up seating frame with pin 1 and polarity matching the footprint (use its `tools/component-reference-overlay.py`). The plain STEP and the board GLB stay clean (no footprint art); publish the marked STEP as an extra variant with provenance. Check the published hero by looking at it: top, oblique, underside and the pin-1 corner. A plain grey thumbnail as a component hero is not done.+Whenever a component page is created or its model changes (here, or through molecule-publish's "create missing component pages" stage), the page's hero GLB follows `eda-component-hero` (adom/eda-engineering): the part with its laser-etched MPN (`adom-aiflow-etch ... --footprint <part.kicad_mod>`: the mark planner places it, see the Marking checklist below), teal dashed copper-pad outlines from the real pads at alpha 0.50, the footprint silkscreen at 0.30, and pin labels at 0.50, as separate nonphysical nodes in the verified Z-up seating frame with pin 1 and polarity matching the footprint (use its `tools/component-reference-overlay.py`). The plain STEP and the board GLB stay clean (no footprint art); publish the marked STEP as an extra variant with provenance. Check the published hero by looking at it: top, oblique, underside and the pin-1 corner. A plain grey thumbnail as a component hero is not done.++### Marking checklist (laser-etched MPN)++1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot.+2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and 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); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun.+5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner. A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path).
skills/aiflow-sourcing/SKILL.md+10−2
@@ -109,8 +109,16 @@ step2glb thumbnail part.step       # look at it  ## Component wiki pages: the hero is annotated and etched -Whenever a component page is created or its model changes (here, or through molecule-publish's "create missing component pages" stage), the page's hero GLB follows `eda-component-hero` (adom/eda-engineering): the part with its laser-etched MPN (`adom-aiflow-etch`, colours kept, along the long axis, or `--along x|y` to keep it off a polarity stripe; a vendor model that already prints its marking is left as it is; every part with an MPN gets it, passives included; LEDs take it on a side face with `--face side` so the window stays clear, and use the coloured default STEP from their Adom basic-part page, with the lens tinted to the datasheet's dominant wavelength), teal dashed copper-pad outlines from the real pads at alpha 0.50, the footprint silkscreen at 0.30, and pin labels at 0.50, as separate nonphysical nodes in the verified Z-up seating frame with pin 1 and polarity matching the footprint (use its `tools/component-reference-overlay.py`). The plain STEP and the board GLB stay clean (no footprint art); publish the marked STEP as an extra variant with provenance. Check the published hero by looking at it: top, oblique, underside and the pin-1 corner. A plain grey thumbnail as a component hero is not done.+Whenever a component page is created or its model changes (here, or through molecule-publish's "create missing component pages" stage), the page's hero GLB follows `eda-component-hero` (adom/eda-engineering): the part with its laser-etched MPN (`adom-aiflow-etch ... --footprint <part.kicad_mod>`: the mark planner places it, see the Marking checklist below; every part with an MPN gets it, passives included; LEDs get it on the front side wall automatically and use the coloured default STEP from their Adom basic-part page, with the lens tinted to the datasheet's dominant wavelength), teal dashed copper-pad outlines from the real pads at alpha 0.50, the footprint silkscreen at 0.30, and pin labels at 0.50, as separate nonphysical nodes in the verified Z-up seating frame with pin 1 and polarity matching the footprint (use its `tools/component-reference-overlay.py`). The plain STEP and the board GLB stay clean (no footprint art); publish the marked STEP as an extra variant with provenance. Check the published hero by looking at it: top, oblique, underside and the pin-1 corner. A plain grey thumbnail as a component hero is not done.++### Marking checklist (laser-etched MPN)++1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot.+2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and 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); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun.+5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner. A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path).  ## Processing STEP files locally -The shared step2glb service comes first, but it is often busy. When a STEP needs local work (turning, merging, reading colours and names through XCAF), suggest the lightweight `cadquery-ocp-novtk` build of OpenCascade's Python bindings in its own venv: about 0.4 GB, against about 1.3 GB for the full `cadquery-ocp`, whose VTK visualisation library STEP processing never uses. Ask the user before installing it, and say how to remove it (delete the venv). `adom-aiflow-etch` does exactly this.+The shared step2glb service comes first, but it is often busy. When a STEP needs local work (turning, merging, reading colours and names through XCAF), suggest the lightweight `cadquery-ocp-novtk` build of OpenCascade's Python bindings in its own venv: about 0.4 GB, against about 1.3 GB for the full `cadquery-ocp`, whose VTK visualisation library STEP processing never uses. Ask the user before installing it, and say how to remove it (delete the venv). `adom-aiflow-etch` no longer needs it: the planner, the etch, the gate and the outline work on the shared service with plain python3 (only `--legacy` uses this venv).
tools/laser-etch/adom-aiflow-etch+38−17
@@ -1,21 +1,42 @@ #!/usr/bin/env bash-# adom-aiflow-etch: colour-preserving, long-axis, FLAT MPN laser etch of a part STEP (see etch_part.py).-# The etch itself runs on the adom-chip-laser service; turning the part and merging the mark need-# OpenCascade (OCP). The shared services come first; the local fallback is the lightweight-# `cadquery-ocp-novtk` build (about 0.4 GB, no VTK visualisation library, which STEP processing never-# needs; the full `cadquery-ocp` is about 1.3 GB). The service does not yet keep colours, turn the part-# or write a flat mark (adom/adom-chip-laser#2), so the tool asks before installing it:-#   AIFLOW_ALLOW_LOCAL_OCC=1 adom-aiflow-etch ...     (makes the venv once; delete it to remove)+# adom-aiflow-etch: the MPN laser mark, placed by construction (see SKILL: Marking checklist).+#   adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step [--footprint part.kicad_mod]+#   adom-aiflow-etch plan    part.step [--footprint fp.kicad_mod] [--mpn MPN] [--face top|side|auto] [--json out.json]+#   adom-aiflow-etch check   part-etched.step [--outline name.svg] [--footprint fp.kicad_mod] [--plan x.mark-plan.json]+#   adom-aiflow-etch outline part.step [--etched part-etched.step] [--footprint fp.kicad_mod] [--mpn MPN] [--out-dir DIR]+#   adom-aiflow-etch test    --step part.step --footprint fp.kicad_mod --mpn MPN --out-dir DIR   (gate self-test)+# The planner, the etch, the gate and the outline run on the shared STEP service (service-step2glb) with+# plain python3: no OpenCascade, no install. `--legacy` runs the previous OpenCascade etch (etch_part_occ.py),+# which needs the lightweight cadquery-ocp-novtk venv (about 0.4 GB, asks first: AIFLOW_ALLOW_LOCAL_OCC=1). set -euo pipefail HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"-VENV="${AIFLOW_OCC_VENV:-$HOME/.local/share/adom-aiflow/occ}"-if [ ! -x "$VENV/bin/python" ] || ! "$VENV/bin/python" -c "import OCP" 2>/dev/null; then-  if [ "${AIFLOW_ALLOW_LOCAL_OCC:-}" != "1" ]; then-    echo "ERROR: adom-aiflow-etch needs OpenCascade (OCP) locally, the lightweight cadquery-ocp-novtk build (about 0.4 GB), because the adom-chip-laser service does not yet keep a model's colours, turn it to its long axis or write a flat mark (adom/adom-chip-laser#2)." >&2-    echo "Hint: ask the user first. With their OK: AIFLOW_ALLOW_LOCAL_OCC=1 adom-aiflow-etch ... (venv at $VENV; delete it when done). Or leave the part unmarked with components --etch off." >&2-    exit 2+PY="${AIFLOW_PYTHON:-python3}"+case "${1:-}" in+  plan)    shift; exec "$PY" "$HERE/mark_plan.py" "$@" ;;+  check)   shift; exec "$PY" "$HERE/mark_check.py" "$@" ;;+  outline) shift; exec "$PY" "$HERE/mark_outline.py" "$@" ;;+  test)    shift; exec "$PY" "$HERE/test_mark_gate.py" "$@" ;;+  version|--version) echo "adom-aiflow-etch (adom-aiflow $(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$HERE/../../package.json" | head -1))"; exit 0 ;;+  -h|--help|help|"")+    sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'+    echo+    "$PY" "$HERE/mark_etch.py" --help+    exit 0 ;;+esac+for a in "$@"; do+  if [ "$a" = "--legacy" ]; then+    args=(); for b in "$@"; do [ "$b" = "--legacy" ] || args+=("$b"); done+    VENV="${AIFLOW_OCC_VENV:-$HOME/.local/share/adom-aiflow/occ}"+    if [ ! -x "$VENV/bin/python" ] || ! "$VENV/bin/python" -c "import OCP" 2>/dev/null; then+      if [ "${AIFLOW_ALLOW_LOCAL_OCC:-}" != "1" ]; then+        echo "ERROR: --legacy needs OpenCascade (OCP) locally, the lightweight cadquery-ocp-novtk build (about 0.4 GB)." >&2+        echo "Hint: drop --legacy: the default etch runs on the shared service and needs nothing installed. With the user's OK: AIFLOW_ALLOW_LOCAL_OCC=1 adom-aiflow-etch --legacy ... (venv at $VENV)." >&2+        exit 2+      fi+      echo "adom-aiflow-etch: setting up OCP (cadquery-ocp-novtk, about 0.4 GB) in $VENV once; delete the folder to remove it" >&2+      python3 -m venv "$VENV" && "$VENV/bin/pip" -q install cadquery-ocp-novtk >&2+    fi+    exec "$VENV/bin/python" "$HERE/etch_part_occ.py" "${args[@]}"   fi-  echo "adom-aiflow-etch: setting up OCP (cadquery-ocp-novtk, about 0.4 GB) in $VENV once; delete the folder to remove it" >&2-  python3 -m venv "$VENV" && "$VENV/bin/pip" -q install cadquery-ocp-novtk >&2-fi-exec "$VENV/bin/python" "$HERE/etch_part.py" "$@"+done+exec "$PY" "$HERE/mark_etch.py" "$@"
tools/laser-etch/etch_part.py+8−97
@@ -1,98 +1,9 @@ #!/usr/bin/env python3-"""Laser-etch a part's MPN onto the top face of its STEP, keeping the model's own colours. The mark is-FLAT by default (each glyph's top face only, a zero-thickness sheet: about a fifth of the triangles of-the service's extruded glyphs; ETCH_SOLID=1 keeps the solids).--The adom-chip-laser service etches across X on a Z-up part and repaints the whole model black and-grey. This stands the part up (--up y for Y-up vendor models), turns it so its LONGEST top-face-direction lies along X, has the service etch that, keeps only the service's inlay, adds it to the-UNTOUCHED original as a separate 'laser_etch' part with its own colour, and turns everything back.-Output is in the original's frame, so the board's model path and transform do not change.--SIDE marks (--face side): for parts whose top must stay clear (an LED's window), the mark goes on a long-side face instead. After standing the part up, the chosen side (--side-dir, default -y: the long side facing--Y in the seating frame) is turned to face +Z, the flat face band furthest from the seating plane on that side-(the moulded body / lens band, never the seam to the PCB substrate or the leads) is picked by side_region.py,-the service is asked again with frac rescaled so the text fills that band minus margins (--margin-w,---margin-h), the mark is centred on the band (fit_mark.py), seated on the real surface (seat_mark.py) and all-turns are undone about the same pivot. --mark auto (the side default) picks dark or light by contrast with-that face's own colour.--Usage: etch_part.py --in part.step --mpn MPN --out part-etched.step [--up z|y] [--frac 0.55]-                    [--mark light|dark|auto|r,g,b] [--service URL]-                    [--face top|side] [--side-dir -y|+y|-x|+x] [--margin-w 0.10] [--margin-h 0.18]-Needs OCP (OpenCascade's Python bindings; `adom-aiflow-etch` makes its own venv on first use)."""-import argparse, json, os, subprocess, sys, tempfile, urllib.request-HERE = os.path.dirname(os.path.abspath(__file__)); PY = sys.executable-ap = argparse.ArgumentParser()-ap.add_argument("--in", dest="src", required=True); ap.add_argument("--mpn", required=True); ap.add_argument("--out", required=True)-ap.add_argument("--up", default="z", choices=["z", "y"]); ap.add_argument("--frac", default="0.55")-ap.add_argument("--along", default="long", choices=["long", "x", "y"], help="direction the text runs in the stood-up frame: long axis (default), or x/y to keep it off a printed band such as an electrolytic's polarity stripe")-ap.add_argument("--mark", default=None, help="light|dark|auto|r,g,b (linear RGB); default light for top, auto for side")-ap.add_argument("--face", default="top", choices=["top", "side"], help="top (default, the original behaviour) or a long side face")-ap.add_argument("--side-band", default="high", choices=["high", "low"], help="which flat band of the side face gets the mark: high (the lens/body band, default) or low (the body strip under a see-through window that runs the full length, e.g. WS2812B-2020)")-ap.add_argument("--side-dir", default="-y", choices=["-y", "+y", "-x", "+x"], help="which side face gets the mark, in the seating frame")-ap.add_argument("--margin-w", type=float, default=0.10); ap.add_argument("--margin-h", type=float, default=0.18); ap.add_argument("--service", default=os.environ.get("ADOM_CHIP_LASER_URL", "https://laserchip-6dn1tl7vjz5u.adom.cloud"))-a = ap.parse_args()-from OCP.STEPControl import STEPControl_Reader-from OCP.Bnd import Bnd_Box-from OCP.BRepBndLib import BRepBndLib-def ext(p):-    r = STEPControl_Reader(); r.ReadFile(p); r.TransferRoots(); b = Bnd_Box(); BRepBndLib.Add_s(r.OneShape(), b)-    mn, mx = b.CornerMin(), b.CornerMax(); return mx.X() - mn.X(), mx.Y() - mn.Y(), mx.Z() - mn.Z()-def run(*args):-    r = subprocess.run([PY, *args], capture_output=True, text=True)-    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")-def runj(*args):-    r = subprocess.run([PY, *args], capture_output=True, text=True)-    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")-    return json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1])   # OCCT writers print banners too-RGB = {"light": "0.78,0.78,0.76", "dark": "0.012,0.012,0.012"}-tmp = tempfile.mkdtemp(prefix="etch-"); cur = a.src; undo = []; info = {}-if a.up == "y":                                     # Y-up vendor model: stand it up first-    run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/up.step", "90", "x"); cur = f"{tmp}/up.step"; undo.append(("-90", "x"))-def etch(model, frac, out):-    req = urllib.request.Request(f"{a.service.rstrip('/')}/etch?mpn={urllib.request.quote(a.mpn)}&frac={frac}", data=open(model, "rb").read(), method="POST")-    with urllib.request.urlopen(req, timeout=300) as resp:-        open(out, "wb").write(resp.read()); return resp.headers.get("X-Etch-Fontsize-Mm")-if a.face == "top":-    dx, dy, dz = ext(cur)-    if (a.along == "long" and dy > dx * 1.02) or a.along == "y":   # text should run along Y: turn Y onto X so the service writes along it-        run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z"); cur = f"{tmp}/long.step"; undo.append(("-90", "z"))-    size = etch(cur, a.frac, f"{tmp}/etched.step")-    rgb = RGB.get(a.mark or "light", a.mark or "light")-    run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched.step", f"{tmp}/merged.step", "0.01", rgb); cur = f"{tmp}/merged.step"-    # the service lays the mark at the bbox top: on an MLCC or resistor that is the end caps, so it floats-    # above the body; seat it on the surface actually under the text-    run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"-else:-    from OCP.STEPControl import STEPControl_Reader as _R-    r_ = _R(); r_.ReadFile(cur); r_.TransferRoots(); b_ = Bnd_Box(); BRepBndLib.Add_s(r_.OneShape(), b_); mn_, mx_ = b_.CornerMin(), b_.CornerMax()-    pv = f"{(mn_.X()+mx_.X())/2},{(mn_.Y()+mx_.Y())/2},{(mn_.Z()+mx_.Z())/2}"          # one pivot for every turn, forward and back-    turn, up = {"-y": (("-90", "x"), (0, 1, 0)), "+y": (("90", "x"), (0, -1, 0)), "-x": (("90", "y"), (1, 0, 0)), "+x": (("-90", "y"), (-1, 0, 0))}[a.side_dir]-    run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/side.step", turn[0], turn[1], pv); cur = f"{tmp}/side.step"; undo.append((str(-float(turn[0])), turn[1], pv))-    dx, dy, dz = ext(cur)-    if (a.along == "long" and dy > dx * 1.02) or a.along == "y":-        run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z", pv); cur = f"{tmp}/long.step"; undo.append(("-90", "z", pv)); up = (-up[1], up[0], up[2])-    reg = runj(os.path.join(HERE, "side_region.py"), cur, "--up", ",".join(map(str, up)), "--pick", a.side_band)-    if a.mark in (None, "auto"):                    # contrast against the face the mark sits on (WCAG-style ratio on linear luminance)-        c = reg["rgb"] or [0.5, 0.5, 0.5]; Y = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]-        rgb = RGB["dark"] if (Y + 0.05) / (0.012 + 0.05) >= (0.6 + 0.05) / (Y + 0.05) else RGB["light"]-    else: rgb = RGB.get(a.mark, a.mark)-    region = ",".join(str(v) for v in reg["region"]); frac = float(a.frac); fits = []-    for attempt in range(3):                        # service sizes the text by frac; rescale until it fills the band-        size = etch(cur, frac, f"{tmp}/etched{attempt}.step")-        run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched{attempt}.step", f"{tmp}/merged{attempt}.step", "0.01", rgb)-        fit = runj(os.path.join(HERE, "fit_mark.py"), f"{tmp}/merged{attempt}.step", f"{tmp}/fit{attempt}.step", region, str(a.margin_w), str(a.margin_h))-        fits.append(dict(frac=frac, fontMm=float(size) if size else None, **fit))-        if 1.0 <= fit["scale_to_fill"] <= 1.06: break   # fills the band within 6 %, never overflows-        frac = round(frac * fit["scale_to_fill"] * 0.99, 4)-    x0, y0, x1, y1 = reg["region"]; m = fits[-1]["mark_region"]-    assert x0 <= m[0] and m[2] <= x1 and y0 <= m[1] and m[3] <= y1, f"mark {m} not inside side face {reg['region']}"-    cur = f"{tmp}/fit{len(fits)-1}.step"-    run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"-    info = dict(sideDir=a.side_dir, sideFace=reg, fits=fits, frac=fits[-1]["frac"])-for i, u in enumerate(reversed(undo)):-    nxt = f"{tmp}/undo{i}.step"; run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, nxt, *u); cur = nxt-os.replace(cur, a.out)-print(json.dumps({"ok": True, "out": a.out, "mpn": a.mpn, "face": a.face, "fontMm": float(size) if size else None, "alongLongAxis": a.along == "long", "along": a.along, "turnedForLongAxis": any(u[1] == "z" for u in undo), "stoodUp": a.up == "y", "mark": rgb, **info}))+"""Entry point kept for scripts that call etch_part.py directly: it now runs the mark planner etch+(mark_etch.py, shared service only, no OpenCascade). The previous OpenCascade etch is etch_part_occ.py+(`adom-aiflow-etch --legacy ...`)."""+import os, sys+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+import mark_etch+if __name__ == "__main__":+    sys.exit(mark_etch.main())
tools/laser-etch/etch_part_occ.pyadded+98
@@ -0,0 +1,98 @@+#!/usr/bin/env python3+"""Laser-etch a part's MPN onto the top face of its STEP, keeping the model's own colours. The mark is+FLAT by default (each glyph's top face only, a zero-thickness sheet: about a fifth of the triangles of+the service's extruded glyphs; ETCH_SOLID=1 keeps the solids).++The adom-chip-laser service etches across X on a Z-up part and repaints the whole model black and+grey. This stands the part up (--up y for Y-up vendor models), turns it so its LONGEST top-face+direction lies along X, has the service etch that, keeps only the service's inlay, adds it to the+UNTOUCHED original as a separate 'laser_etch' part with its own colour, and turns everything back.+Output is in the original's frame, so the board's model path and transform do not change.++SIDE marks (--face side): for parts whose top must stay clear (an LED's window), the mark goes on a long+side face instead. After standing the part up, the chosen side (--side-dir, default -y: the long side facing+-Y in the seating frame) is turned to face +Z, the flat face band furthest from the seating plane on that side+(the moulded body / lens band, never the seam to the PCB substrate or the leads) is picked by side_region.py,+the service is asked again with frac rescaled so the text fills that band minus margins (--margin-w,+--margin-h), the mark is centred on the band (fit_mark.py), seated on the real surface (seat_mark.py) and all+turns are undone about the same pivot. --mark auto (the side default) picks dark or light by contrast with+that face's own colour.++Usage: etch_part.py --in part.step --mpn MPN --out part-etched.step [--up z|y] [--frac 0.55]+                    [--mark light|dark|auto|r,g,b] [--service URL]+                    [--face top|side] [--side-dir -y|+y|-x|+x] [--margin-w 0.10] [--margin-h 0.18]+Needs OCP (OpenCascade's Python bindings; `adom-aiflow-etch` makes its own venv on first use)."""+import argparse, json, os, subprocess, sys, tempfile, urllib.request+HERE = os.path.dirname(os.path.abspath(__file__)); PY = sys.executable+ap = argparse.ArgumentParser()+ap.add_argument("--in", dest="src", required=True); ap.add_argument("--mpn", required=True); ap.add_argument("--out", required=True)+ap.add_argument("--up", default="z", choices=["z", "y"]); ap.add_argument("--frac", default="0.55")+ap.add_argument("--along", default="long", choices=["long", "x", "y"], help="direction the text runs in the stood-up frame: long axis (default), or x/y to keep it off a printed band such as an electrolytic's polarity stripe")+ap.add_argument("--mark", default=None, help="light|dark|auto|r,g,b (linear RGB); default light for top, auto for side")+ap.add_argument("--face", default="top", choices=["top", "side"], help="top (default, the original behaviour) or a long side face")+ap.add_argument("--side-band", default="high", choices=["high", "low"], help="which flat band of the side face gets the mark: high (the lens/body band, default) or low (the body strip under a see-through window that runs the full length, e.g. WS2812B-2020)")+ap.add_argument("--side-dir", default="-y", choices=["-y", "+y", "-x", "+x"], help="which side face gets the mark, in the seating frame")+ap.add_argument("--margin-w", type=float, default=0.10); ap.add_argument("--margin-h", type=float, default=0.18); ap.add_argument("--service", default=os.environ.get("ADOM_CHIP_LASER_URL", "https://laserchip-6dn1tl7vjz5u.adom.cloud"))+a = ap.parse_args()+from OCP.STEPControl import STEPControl_Reader+from OCP.Bnd import Bnd_Box+from OCP.BRepBndLib import BRepBndLib+def ext(p):+    r = STEPControl_Reader(); r.ReadFile(p); r.TransferRoots(); b = Bnd_Box(); BRepBndLib.Add_s(r.OneShape(), b)+    mn, mx = b.CornerMin(), b.CornerMax(); return mx.X() - mn.X(), mx.Y() - mn.Y(), mx.Z() - mn.Z()+def run(*args):+    r = subprocess.run([PY, *args], capture_output=True, text=True)+    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")+def runj(*args):+    r = subprocess.run([PY, *args], capture_output=True, text=True)+    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")+    return json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1])   # OCCT writers print banners too+RGB = {"light": "0.78,0.78,0.76", "dark": "0.012,0.012,0.012"}+tmp = tempfile.mkdtemp(prefix="etch-"); cur = a.src; undo = []; info = {}+if a.up == "y":                                     # Y-up vendor model: stand it up first+    run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/up.step", "90", "x"); cur = f"{tmp}/up.step"; undo.append(("-90", "x"))+def etch(model, frac, out):+    req = urllib.request.Request(f"{a.service.rstrip('/')}/etch?mpn={urllib.request.quote(a.mpn)}&frac={frac}", data=open(model, "rb").read(), method="POST")+    with urllib.request.urlopen(req, timeout=300) as resp:+        open(out, "wb").write(resp.read()); return resp.headers.get("X-Etch-Fontsize-Mm")+if a.face == "top":+    dx, dy, dz = ext(cur)+    if (a.along == "long" and dy > dx * 1.02) or a.along == "y":   # text should run along Y: turn Y onto X so the service writes along it+        run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z"); cur = f"{tmp}/long.step"; undo.append(("-90", "z"))+    size = etch(cur, a.frac, f"{tmp}/etched.step")+    rgb = RGB.get(a.mark or "light", a.mark or "light")+    run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched.step", f"{tmp}/merged.step", "0.01", rgb); cur = f"{tmp}/merged.step"+    # the service lays the mark at the bbox top: on an MLCC or resistor that is the end caps, so it floats+    # above the body; seat it on the surface actually under the text+    run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"+else:+    from OCP.STEPControl import STEPControl_Reader as _R+    r_ = _R(); r_.ReadFile(cur); r_.TransferRoots(); b_ = Bnd_Box(); BRepBndLib.Add_s(r_.OneShape(), b_); mn_, mx_ = b_.CornerMin(), b_.CornerMax()+    pv = f"{(mn_.X()+mx_.X())/2},{(mn_.Y()+mx_.Y())/2},{(mn_.Z()+mx_.Z())/2}"          # one pivot for every turn, forward and back+    turn, up = {"-y": (("-90", "x"), (0, 1, 0)), "+y": (("90", "x"), (0, -1, 0)), "-x": (("90", "y"), (1, 0, 0)), "+x": (("-90", "y"), (-1, 0, 0))}[a.side_dir]+    run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/side.step", turn[0], turn[1], pv); cur = f"{tmp}/side.step"; undo.append((str(-float(turn[0])), turn[1], pv))+    dx, dy, dz = ext(cur)+    if (a.along == "long" and dy > dx * 1.02) or a.along == "y":+        run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z", pv); cur = f"{tmp}/long.step"; undo.append(("-90", "z", pv)); up = (-up[1], up[0], up[2])+    reg = runj(os.path.join(HERE, "side_region.py"), cur, "--up", ",".join(map(str, up)), "--pick", a.side_band)+    if a.mark in (None, "auto"):                    # contrast against the face the mark sits on (WCAG-style ratio on linear luminance)+        c = reg["rgb"] or [0.5, 0.5, 0.5]; Y = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]+        rgb = RGB["dark"] if (Y + 0.05) / (0.012 + 0.05) >= (0.6 + 0.05) / (Y + 0.05) else RGB["light"]+    else: rgb = RGB.get(a.mark, a.mark)+    region = ",".join(str(v) for v in reg["region"]); frac = float(a.frac); fits = []+    for attempt in range(3):                        # service sizes the text by frac; rescale until it fills the band+        size = etch(cur, frac, f"{tmp}/etched{attempt}.step")+        run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched{attempt}.step", f"{tmp}/merged{attempt}.step", "0.01", rgb)+        fit = runj(os.path.join(HERE, "fit_mark.py"), f"{tmp}/merged{attempt}.step", f"{tmp}/fit{attempt}.step", region, str(a.margin_w), str(a.margin_h))+        fits.append(dict(frac=frac, fontMm=float(size) if size else None, **fit))+        if 1.0 <= fit["scale_to_fill"] <= 1.06: break   # fills the band within 6 %, never overflows+        frac = round(frac * fit["scale_to_fill"] * 0.99, 4)+    x0, y0, x1, y1 = reg["region"]; m = fits[-1]["mark_region"]+    assert x0 <= m[0] and m[2] <= x1 and y0 <= m[1] and m[3] <= y1, f"mark {m} not inside side face {reg['region']}"+    cur = f"{tmp}/fit{len(fits)-1}.step"+    run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"+    info = dict(sideDir=a.side_dir, sideFace=reg, fits=fits, frac=fits[-1]["frac"])+for i, u in enumerate(reversed(undo)):+    nxt = f"{tmp}/undo{i}.step"; run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, nxt, *u); cur = nxt+os.replace(cur, a.out)+print(json.dumps({"ok": True, "out": a.out, "mpn": a.mpn, "face": a.face, "fontMm": float(size) if size else None, "alongLongAxis": a.along == "long", "along": a.along, "turnedForLongAxis": any(u[1] == "z" for u in undo), "stoodUp": a.up == "y", "mark": rgb, **info}))
tools/laser-etch/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())
tools/laser-etch/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())
tools/laser-etch/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)
tools/laser-etch/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())
tools/laser-etch/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()
tools/laser-etch/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])
tools/laser-etch/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;
tools/laser-etch/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)
tools/laser-etch/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())