← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.93"+version = "0.1.94" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.93"+version = "0.1.94" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.93"+version = "0.1.94" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.93"+version = "0.1.94" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+26−7
@@ -12,7 +12,7 @@ Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kic  Every state-changing command takes `--ai-thread "<your thread name>"`. Every run lives in one directory (`--run DIR`, default `./aiflow-run`) with a `run.json` manifest: the clock, the plan, the decisions, every stage's start and end, the outcomes, the capture markers and the summary. The manifest is the comparison; nothing counts that is not in it. -Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it. `intake`, `start`, `prompt`, `stage end` and every `step` end with a `NEXT:` line naming the next required step and its exact command. A run that sits in one step for more than twice that step's expected minutes (`minutes` in flows/board.json) gets a WARNING on every command and in `finish-guard`; it never blocks, it means you are probably doing later steps' work undeclared.+Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it. `intake`, `start`, `prompt`, `stage end` and every `step` end with a `NEXT:` line naming the next required step and its exact command. A run that sits in one step for more than twice that step's expected minutes (`minutes` in flows/board.json) gets a WARNING on every command and in `finish-guard`; it never blocks, it means you are probably doing later steps' work undeclared. The warning waits while an `exec` of the run is still running (a long solve is the step's own work).  ## Continue to the requested finish line @@ -64,6 +64,13 @@ A board with differential pairs, impedance control or length matching (PCIe, OCu 3. `stage end route`. 4. Record the numbers: `evidence add --kind calcs --file design/impedance.json` with the stackup, the trace width and gap per layer for the target impedance, the propagation delay per layer, and the skew per pair and per lane group after routing. +The plan JSON shape (full reference: docs/plan-schema.md):++- Top level: `{"nets": [entry, ...]}` or a bare `[entry, ...]`.+- Entry: `{"net": "VIN", "width": 0.25, "viaSize": 0.6, "viaDrill": 0.3, "paths": [[point, point, ...], ...]}`; `net` and `paths` (or one path as `points`) are required, the sizes are mm with those defaults.+- Point: a pad key `"U1.3"`, `[x, y]` in mm, `{"x": x, "y": y, "layer": "B.Cu"}` or `{"pad": "J1.1", "layer": "B.Cu"}`. The first point's layer (default F.Cu) starts the path.+- Via: a point whose layer differs from the current one drops a through via there and the path continues on that layer. For a via with copper on one layer only (a drop into a pour), end the path with that point at the same x, y as the point before it.+ If the binary should do this step itself, file it (see Give it back).  ## No step is skipped@@ -73,12 +80,22 @@ design, test-plan, sourcing, library, schematic, simulation, placement, routing, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` refuses while any required step before x is not done, and a step is done only when its own command ran during its latest visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land-moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `fusion silkscreen`, `fusion-export` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it+moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record`+(Fusion: `fusion silkscreen`), `export` (Fusion: `fusion export`) ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it draws an arc on the timeline, and every later step becomes a redo that must be done again in order. `finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never jump past it. +On the KiCad lane, `silkscreen-audit --input <audit.json> --out <result.json> --record` records the+silkscreen proof only when the audit passes and its inventory covers the board: every footprint by its+reference (id `R1`, or `R1:body`, `R1-value`), a via or hole item for every via, and at least 0.5 visible+labels per footprint. `export` writes the manufacturing set with kicad-cli (ADOM_AIFLOW_KICAD_CLI;+`adom-aiflow-kicad-cli-remote` in a container): Gerbers and drill in `<run>/manufacturing/gerbers/`, the+CPL and a BOM built from the board's footprints beside them. It reads every file back (copper layers from+the Gerber file functions, drill hits against vias and drilled pads, CPL and BOM references, sides and top+rotations against the placed parts, DNP and exclude attributes) and records the export proof only when all+of it matches. Bottom-side CPL rotations are counted, not compared; check them in the fab's preview.+ ## The timeline (show it, keep it up)  ### Automatic dashboard (all AI engines)@@ -190,6 +207,8 @@ Per-step clips stay RAW: the recording, its 10x cut and its motion-only action c  After `analyze current` and `analyze thermal`, do the analysis yourself and draw it: a current density map on the copper (a DC conduction solve on the board's filled polygons between each loaded net's terminals) and for heat two drawings: the heat flow in the copper layer by layer, like the current picture (a multi-layer solve, each chip's watts spreading on its layer and going down its vias, the vias drawn, each chip's split between layer, vias and bare dielectric), then the temperature rise over the whole board (the FR4 spreading); each a 1920x1080 PNG, then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old png>` when you redo one; it goes fullscreen into the video and onto the page under the step), then `tour nets --nets <the pour nets the hint lists>` under the same step so the clip ends on the copper the analysis judged, lit net by net. +Clip checks and cuts need ffmpeg and ffprobe. When either is missing, every clip stop says so by name and leaves the clip unjudged, and `recut` and `compose` refuse; install ffmpeg, then `recut` (it clears a blank flag an earlier check gave) and `compose`.+ No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence.  `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers.@@ -204,7 +223,7 @@ Deterministic checks that refuse, each an OK/ERROR with hints:  - **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed. - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional).-- **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not.+- **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the JLCPCB pack for the board's copper layer count: on 2 layers the shipped profile, installed with `rules install --profile jlcpcb --layers 2` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file, keeps it as `.kicad_dru.bak` and says what it replaced); on 4, 6 or more layers `rules use --profile jlcpcb --layers <n>` loads the pack from adom/pcb-design-rules (`rules install` refuses a layer count it does not ship). A layer suffix pins the count: `"rules": "jlcpcb-6L"`. When the spec has no `fab.rules`, the gate, finish and `rules check` use what `rules use` recorded in run.json `fabRules` (an intake run has no spec yet), and warn when the spec and `fabRules` disagree. `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith. - **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask. @@ -289,10 +308,10 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched ### 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.+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; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); 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. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are. 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).+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. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred.+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 (text across an IC's pin rows always FAILs). 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/checks.rs+205−18
@@ -337,35 +337,150 @@ pub fn molecule_check(b: &Board, spec: &Value, before: Option<&Board>) -> Findin pub const JLCPCB_2L: &str = include_str!("../../../rules/jlcpcb-2L.kicad_dru"); pub const JLCPCB_MARKER: &str = "adom-aiflow rules profile: jlcpcb-2L"; -/// The rules profile the spec names: `"fab": {"rules": "fab" | "jlcpcb"}`. A plain-text `fab`-/// (older specs) names no profile.+/// The rules profile the spec names: `"fab": {"rules": "fab" | "jlcpcb" | "jlcpcb-6L" | <slug>}`.+/// A plain-text `fab` (older specs) names no profile. pub fn rules_profile(spec: &Value) -> Option<String> {     spec.get("fab").and_then(|f| f.get("rules")).and_then(|r| r.as_str()).map(str::to_string) } +/// Split a profile name into its base and an optional copper-layer suffix:+/// `jlcpcb-6L` -> ("jlcpcb", Some(6)), `jlcpcb` -> ("jlcpcb", None). The base is lowercased and+/// the 3rd party fab's aliases keep their meaning (`fab` stays `fab`, `3rdPartyFab` becomes+/// `adom-3rdpartyfab`, the slug `rules use` writes).+pub fn split_profile(profile: &str) -> (String, Option<u32>) {+    let p = profile.trim();+    let (base, layers) = match p.rsplit_once('-') {+        Some((b, suf)) if suf.len() >= 2 && (suf.ends_with('L') || suf.ends_with('l'))+            && suf[..suf.len() - 1].chars().all(|c| c.is_ascii_digit()) =>+            (b, suf[..suf.len() - 1].parse::<u32>().ok()),+        _ => (p, None),+    };+    let base = match base.to_ascii_lowercase().as_str() {+        "3rdpartyfab" | "3rd-party-fab" => "adom-3rdpartyfab".to_string(),+        o => o.to_string(),+    };+    (base, layers)+}++/// The number of copper layers the board declares, when the board can be read.+pub fn board_copper_layers(board: &str) -> Option<u32> {+    let b = Board::load(board).ok()?;+    let n = b.copper_layers.len() as u32;+    (n > 0).then_some(n)+}++/// The rules the run should be checked against: the spec's `fab.rules` (with an optional+/// `fab.layers`), else the run's `fabRules` (`rules use` records {profile, slug, layers, file},+/// also in an intake run that had no spec yet). The third value warns when the two disagree.+pub fn rules_target(spec: &Value, fab_rules: &Value) -> Option<(String, Option<u32>, Option<String>)> {+    let run_slug = fab_rules.get("slug").and_then(|s| s.as_str()).or_else(|| fab_rules.get("profile").and_then(|s| s.as_str()));+    let run_layers = fab_rules.get("layers").and_then(|l| l.as_u64()).map(|l| l as u32);+    match rules_profile(spec) {+        Some(p) => {+            let (base, suffix) = split_profile(&p);+            let layers = suffix.or_else(|| spec.get("fab").and_then(|f| f.get("layers")).and_then(|l| l.as_u64()).map(|l| l as u32));+            let warning = run_slug.and_then(|rs| {+                let (rb, rsuf) = split_profile(rs);+                let rl = rsuf.or(run_layers);+                let same_base = rb == base || (is_fab(&rb) && is_fab(&base));+                let same_layers = match (layers, rl) { (Some(a), Some(b)) => a == b, _ => true };+                (!same_base || !same_layers).then(|| format!(+                    "the spec names fab.rules {p:?} but run.json fabRules (from `rules use`) is {}{}; the spec wins. Run `adom-aiflow rules use --profile {} --layers <n>` again, or fix fab.rules in the spec, so they agree",+                    rs, rl.map(|l| format!(" {l}L")).unwrap_or_default(), base))+            });+            Some((p, layers, warning))+        }+        None => run_slug.map(|s| {+            let (_, suffix) = split_profile(s);+            (s.to_string(), suffix.or(run_layers), None)+        }),+    }+}++fn is_fab(base: &str) -> bool { base == "fab" || base == "adom-3rdpartyfab" }++/// The `(slug, layers)` a `rules use` pack declares in its header+/// (`# adom-aiflow rules: <slug> <n>L (from ...)`), or the shipped jlcpcb-2L marker.+pub fn pack_of(text: &str) -> Option<(String, Option<u32>)> {+    for line in text.lines() {+        if let Some(rest) = line.split("adom-aiflow rules: ").nth(1) {+            let mut it = rest.split_whitespace();+            let slug = it.next()?.to_ascii_lowercase();+            let layers = it.next().and_then(|l| l.strip_suffix('L').or_else(|| l.strip_suffix('l'))).and_then(|n| n.parse().ok());+            return Some((slug, layers));+        }+    }+    text.contains(JLCPCB_MARKER).then(|| ("jlcpcb".to_string(), Some(2)))+}+ /// Whether the board's project carries rules matching the profile. Ok(text) says what matched.-pub fn rules_check(profile: &str, board: &str) -> Result<String, String> {+/// The copper layer count comes from the profile's suffix (`jlcpcb-6L`), else the board itself,+/// else `fallback_layers` (the spec's or the run's record).+pub fn rules_check_layers(profile: &str, board: &str, fallback_layers: Option<u32>) -> Result<String, String> {     let dru = Path::new(board).with_extension("kicad_dru");     let text = std::fs::read_to_string(&dru).ok();-    match (profile, text) {-        ("jlcpcb", Some(t)) if t.contains(JLCPCB_MARKER) => Ok(format!("{} carries the jlcpcb-2L profile", dru.display())),-        ("jlcpcb", Some(_)) => Err(format!("the spec names the jlcpcb rules profile but {} is another rules file", dru.display())),-        ("jlcpcb", None) => Err(format!("the spec names the jlcpcb rules profile but the board has no {}", dru.display())),-        ("fab", Some(t)) if t.contains(JLCPCB_MARKER) => Err(format!("the spec names the 3rd party fab rules profile but {} is the shipped jlcpcb-2L file", dru.display())),-        ("fab", Some(t)) if t.contains("(rule") => Ok(format!("{} carries the project's own fab rules", dru.display())),-        ("adom-3rdpartyfab", Some(t)) if t.contains("3rd party fab") && t.contains("(rule") => Ok(format!("{} carries the 3rdPartyFab rules", dru.display())),-        ("fab", _) => Err(format!("the spec names the 3rd party fab rules profile but the board has no {} with rules in it", dru.display())),-        (other, Some(t)) if t.contains(&format!("adom-aiflow rules: {other} ")) => Ok(format!("{} carries the {other} rule pack", dru.display())),-        (other, _) => Err(format!("the spec names the {other:?} rules but {} does not carry that pack; `adom-aiflow rules use --profile {other}` installs it", dru.display())),+    let (base, _) = split_profile(profile);+    let layers = effective_layers(profile, board, fallback_layers);+    let want = |b: &str| match layers { Some(n) => format!("{b}-{n}L"), None => b.to_string() };+    if base == "fab" {+        return match text {+            Some(t) if t.contains(JLCPCB_MARKER) => Err(format!("the spec names the 3rd party fab rules profile but {} is the shipped jlcpcb-2L file", dru.display())),+            Some(t) if t.contains("(rule") => Ok(format!("{} carries the project's own fab rules", dru.display())),+            _ => Err(format!("the spec names the 3rd party fab rules profile but the board has no {} with rules in it", dru.display())),+        };+    }+    if base == "adom-3rdpartyfab" {+        if let Some(t) = &text {+            if t.contains("3rd party fab") && t.contains("(rule") && pack_of(t).is_none() {+                return Ok(format!("{} carries the 3rdPartyFab rules", dru.display()));+            }+        }+    }+    let Some(t) = text else {+        return Err(format!("the spec names the {} rules profile but the board has no {}", want(&base), dru.display()));+    };+    match pack_of(&t) {+        Some((slug, pl)) if slug == base => match (layers, pl) {+            (Some(n), Some(m)) if n != m => Err(format!("the board has {n} copper layers but {} carries the {base}-{m}L pack, not {base}-{n}L", dru.display())),+            (_, m) => Ok(format!("{} carries the {base}{} rules", dru.display(), m.map(|m| format!("-{m}L")).unwrap_or_default())),+        },+        Some((slug, pl)) => Err(format!("the spec names the {} rules profile but {} carries the {slug}{} pack", want(&base), dru.display(), pl.map(|m| format!("-{m}L")).unwrap_or_default())),+        None => Err(format!("the spec names the {} rules profile but {} is another rules file", want(&base), dru.display())),     } } -pub fn rules_hint(profile: &str) -> String {-    if profile == "jlcpcb" {-        "Install the shipped profile beside the board: `adom-aiflow rules install --profile jlcpcb` (--force replaces another rules file; then adopt-board or start again so the run's snapshot carries it).".into()-    } else {-        "The 3rd party fab's rules are private and are not shipped: copy the project's own <board>.kicad_dru beside the board (the project keeps it under rules/), then run the gate again.".into()+/// The copper layer count a rules check judges by: the profile's suffix, the board, the record.+pub fn effective_layers(profile: &str, board: &str, fallback_layers: Option<u32>) -> Option<u32> {+    split_profile(profile).1.or_else(|| board_copper_layers(board)).or(fallback_layers)+}++/// A rules check plus the hint that fixes it, with the target from `rules_target`.+pub fn rules_gate(profile: &str, board: &str, fallback_layers: Option<u32>) -> Result<String, (String, String)> {+    rules_check_layers(profile, board, fallback_layers)+        .map_err(|e| (e, rules_hint_layers(profile, effective_layers(profile, board, fallback_layers))))+}++/// The command that puts the right rules beside the board. `layers` is the board's copper count+/// (or the spec's/run's) when known.+pub fn rules_hint_layers(profile: &str, layers: Option<u32>) -> String {+    let (base, suffix) = split_profile(profile);+    let layers = suffix.or(layers);+    if base == "fab" {+        return "The 3rd party fab's rules are private and are not shipped: copy the project's own <board>.kicad_dru beside the board (the project keeps it under rules/), then run the gate again.".into();+    }+    if base == "jlcpcb" && layers.unwrap_or(2) == 2 {+        return "Install the shipped profile beside the board: `adom-aiflow rules install --profile jlcpcb --layers 2` (--force replaces another rules file; then adopt-board or start again so the run's snapshot carries it).".into();     }+    let shown = if base == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { base.clone() };+    match layers {+        Some(n) => format!("Load the {n}-layer pack from adom/pcb-design-rules beside the board: `adom-aiflow rules use --profile {shown} --layers {n}` (--force replaces another rules file; `rules list` shows the packs). Only jlcpcb 2-layer ships with adom-aiflow (`rules install`)."),+        None => format!("Load the pack from adom/pcb-design-rules beside the board: `adom-aiflow rules use --profile {shown} --layers <copper layers>` (`rules list` shows the packs)."),+    }+}++/// The shipped rule packs `rules install` can write: (profile, layers, text).+pub fn shipped_pack(base: &str, layers: u32) -> Option<&'static str> {+    match (base, layers) { ("jlcpcb", 2) => Some(JLCPCB_2L), _ => None } }  // ----------------------------------------------------------------------------- model orientation (#20)@@ -654,6 +769,78 @@ mod tests {         assert!(f.errors.iter().any(|e| e.contains("moved since the run started")));     } +    fn rules_check(profile: &str, board: &str) -> Result<String, String> { rules_check_layers(profile, board, None) }++    fn six_layer_board(path: &Path) {+        std::fs::write(path, r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "In2.Cu" signal) (3 "In3.Cu" signal) (4 "In4.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user)) (gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts")))"#).unwrap();+    }++    #[test]+    fn profile_names_carry_layers() {+        assert_eq!(split_profile("jlcpcb-6L"), ("jlcpcb".into(), Some(6)));+        assert_eq!(split_profile("JLCPCB-4l"), ("jlcpcb".into(), Some(4)));+        assert_eq!(split_profile("jlcpcb"), ("jlcpcb".into(), None));+        assert_eq!(split_profile("3rdPartyFab"), ("adom-3rdpartyfab".into(), None));+        assert_eq!(split_profile("pcbway-hdi-8L"), ("pcbway-hdi".into(), Some(8)));+        assert_eq!(split_profile("fab"), ("fab".into(), None));+        assert_eq!(pack_of("# adom-aiflow rules: jlcpcb 6L (from adom/pcb-design-rules)\n(rule \"x\")"), Some(("jlcpcb".into(), Some(6))));+        assert_eq!(pack_of(JLCPCB_2L), Some(("jlcpcb".into(), Some(2))));+        assert_eq!(pack_of("(version 1)\n(rule \"x\")"), None);+        assert_eq!(shipped_pack("jlcpcb", 2), Some(JLCPCB_2L));+        assert_eq!(shipped_pack("jlcpcb", 6), None);+    }++    #[test]+    fn jlcpcb_pack_follows_board_layers() {+        let dir = std::env::temp_dir().join(format!("aiflow-rules6-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let b = dir.join("b6.kicad_pcb");+        six_layer_board(&b);+        let bs = b.to_str().unwrap();+        assert_eq!(board_copper_layers(bs), Some(6));+        let dru = b.with_extension("kicad_dru");+        // the shipped 2-layer pack on a 6-layer board is refused, both as "jlcpcb" and "jlcpcb-6L"+        std::fs::write(&dru, JLCPCB_2L).unwrap();+        let e = rules_check("jlcpcb", bs).unwrap_err();+        assert!(e.contains("6 copper layers") && e.contains("jlcpcb-2L"), "{e}");+        assert!(rules_check("jlcpcb-6L", bs).is_err());+        let (_, hint) = rules_gate("jlcpcb", bs, None).unwrap_err();+        assert!(hint.contains("rules use --profile jlcpcb --layers 6"), "{hint}");+        // the 6L pack `rules use` writes passes as "jlcpcb" and as "jlcpcb-6L" (no jlcpcb-6l-6L lookup)+        std::fs::write(&dru, "# adom-aiflow rules: jlcpcb 6L (from adom/pcb-design-rules)\n(version 1)\n(rule \"x\" (constraint clearance (min 0.1mm)))\n").unwrap();+        assert!(rules_check("jlcpcb", bs).is_ok());+        let ok = rules_check("jlcpcb-6L", bs).unwrap();+        assert!(ok.contains("jlcpcb-6L"), "{ok}");+        assert!(rules_check("jlcpcb-4L", bs).is_err());+        assert!(rules_check("pcbway", bs).unwrap_err().contains("carries the jlcpcb-6L pack"));+        // a board that cannot be read falls back to the recorded layers+        let missing = dir.join("none.kicad_pcb");+        std::fs::write(missing.with_extension("kicad_dru"), JLCPCB_2L).unwrap();+        assert!(rules_check_layers("jlcpcb", missing.to_str().unwrap(), Some(6)).is_err());+        assert!(rules_check_layers("jlcpcb", missing.to_str().unwrap(), Some(2)).is_ok());+        assert!(rules_hint_layers("jlcpcb", Some(2)).contains("rules install --profile jlcpcb --layers 2"));+        assert!(rules_hint_layers("jlcpcb-6L", None).contains("rules use --profile jlcpcb --layers 6"));+        std::fs::remove_dir_all(dir).unwrap();+    }++    #[test]+    fn rules_target_falls_back_to_run_fab_rules() {+        let fr = json!({"profile": "jlcpcb", "slug": "jlcpcb", "layers": 6, "file": "jlcpcb-6L.kicad_dru"});+        // no fab.rules in the spec: the run's record decides, layers included+        let (p, l, w) = rules_target(&json!({}), &fr).unwrap();+        assert_eq!((p.as_str(), l, w), ("jlcpcb", Some(6), None));+        // the spec agrees: no warning+        assert!(rules_target(&json!({"fab": {"rules": "jlcpcb"}}), &fr).unwrap().2.is_none());+        assert!(rules_target(&json!({"fab": {"rules": "jlcpcb-6L"}}), &fr).unwrap().2.is_none());+        // the spec disagrees on layers or fab: the spec wins and the run warns+        let (p, l, w) = rules_target(&json!({"fab": {"rules": "jlcpcb-2L"}}), &fr).unwrap();+        assert_eq!((p.as_str(), l), ("jlcpcb-2L", Some(2)));+        assert!(w.unwrap().contains("fabRules"));+        assert!(rules_target(&json!({"fab": {"rules": "fab"}}), &fr).unwrap().2.is_some());+        assert!(rules_target(&json!({"fab": {"rules": "fab"}}), &json!({"slug": "adom-3rdpartyfab", "layers": 2})).unwrap().2.is_none());+        assert!(rules_target(&json!({}), &Value::Null).is_none());+    }+     #[test]     fn rules_profiles() {         let dir = std::env::temp_dir().join(format!("aiflow-rules-{}", std::process::id()));
crates/adom-aiflow/src/export.rsadded+511
@@ -0,0 +1,511 @@+//! The KiCad lane's manufacturing set (issue #48): Gerbers, drill, pick and place (CPL) and BOM written+//! from the finished board with the native kicad-cli (ADOM_AIFLOW_KICAD_CLI, else `kicad-cli`; in a+//! container adom-aiflow-kicad-cli-remote runs a desktop's), then every file read back against the board+//! before it is called good: the copper layer count from the Gerbers' own file functions, the drill hits+//! against the board's vias and drilled pads, the CPL and BOM references, sides and rotations against the+//! placed footprints. A set that does not match is refused and records no proof.++use aiflow_board::{sx, Board};+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};+use std::path::Path;++/// One footprint as the fab sees it.+#[derive(Debug, Clone, Default)]+pub struct Part {+    pub value: String,+    pub footprint: String,+    pub mpn: String,+    pub manufacturer: String,+    pub bottom: bool,+    pub rot: f64,+    pub dnp: bool,+    pub in_bom: bool,+    pub in_pos: bool,+}++/// What the files must show: the placed parts, the copper layers and the holes.+#[derive(Debug, Default)]+pub struct Inventory {+    pub parts: BTreeMap<String, Part>,+    pub copper_layers: Vec<String>,+    pub vias: usize,+    pub plated_pad_holes: usize,+    pub npth_pad_holes: usize,+}++fn prop(fp: &sx::Node, names: &[&str]) -> String {+    for p in fp.find_all("property") {+        if let Some(k) = p.atom(1) { if names.iter().any(|n| n.eq_ignore_ascii_case(k)) { return p.atom(2).unwrap_or("").to_string(); } }+    }+    String::new()+}++/// A footprint flag in either spelling: an atom of `(attr ...)` or its own `(flag yes)` node.+fn flag(fp: &sx::Node, name: &str) -> bool {+    let in_attr = fp.find("attr").map(|a| a.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == name))).unwrap_or(false);+    in_attr || fp.find(name).map(|n| n.value() != Some("no")).unwrap_or(false)+}++/// The size of a hole: the first number of `(drill ...)` (`(drill 0.8)`, `(drill oval 1.0 2.0)`).+fn hole_size(pad: &sx::Node) -> f64 {+    pad.find("drill").and_then(|d| d.items.iter().find_map(|it| match it { sx::Item::Atom { text, .. } => text.parse::<f64>().ok(), _ => None })).unwrap_or(0.0)+}++pub fn inventory(b: &Board) -> Inventory {+    let mut inv = Inventory { copper_layers: b.copper_layers.clone(), vias: b.root.find_all("via").count(), ..Default::default() };+    for fp in b.root.find_all("footprint") {+        for pad in fp.find_all("pad") {+            if hole_size(pad) <= 0.0 { continue; }+            match pad.atom(2) { Some("thru_hole") => inv.plated_pad_holes += 1, Some("np_thru_hole") => inv.npth_pad_holes += 1, _ => {} }+        }+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        let at = fp.find("at");+        let value = { let v = prop(fp, &["Value"]); if v.is_empty() { fp.find_all("fp_text").find(|t| t.atom(1) == Some("value")).and_then(|t| t.atom(2)).unwrap_or("").to_string() } else { v } };+        let virt = flag(fp, "virtual");+        inv.parts.insert(reference, Part {+            value,+            footprint: fp.value().unwrap_or("").to_string(),+            mpn: prop(fp, &["MPN", "Manufacturer_Part_Number", "Manufacturer Part Number", "MFR_PN", "PartNumber", "Part Number"]),+            manufacturer: prop(fp, &["Manufacturer", "MFR", "Manufacturer_Name"]),+            bottom: fp.find("layer").and_then(|l| l.value()) == Some("B.Cu"),+            rot: at.and_then(|a| a.number(3)).unwrap_or(0.0),+            dnp: flag(fp, "dnp"),+            in_bom: !(virt || flag(fp, "exclude_from_bom")),+            in_pos: !(virt || flag(fp, "exclude_from_pos_files")),+        });+    }+    inv+}++/// R2 before R10.+fn natural(a: &str) -> (String, u64, String) {+    let p: String = a.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let rest = &a[p.len()..];+    let d: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+    (p, d.parse().unwrap_or(0), rest[d.len()..].to_string())+}++fn csv_field(s: &str) -> String {+    if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }+}++/// A CSV reader for what kicad-cli and this module write (quoted fields, doubled quotes).+pub fn read_csv(text: &str) -> Vec<Vec<String>> {+    let mut rows = Vec::new();+    let mut row = Vec::new();+    let mut f = String::new();+    let mut quoted = false;+    let mut chars = text.trim_start_matches('\u{feff}').chars().peekable();+    while let Some(c) = chars.next() {+        if quoted {+            if c == '"' { if chars.peek() == Some(&'"') { f.push('"'); chars.next(); } else { quoted = false; } } else { f.push(c); }+            continue;+        }+        match c {+            '"' => quoted = true,+            ',' => row.push(std::mem::take(&mut f)),+            '\r' => {}+            '\n' => { row.push(std::mem::take(&mut f)); if row.iter().any(|x| !x.trim().is_empty()) { rows.push(std::mem::take(&mut row)); } else { row.clear(); } }+            _ => f.push(c),+        }+    }+    if !f.is_empty() || !row.is_empty() { row.push(f); if row.iter().any(|x| !x.trim().is_empty()) { rows.push(row); } }+    rows+}++/// The BOM from the board's own footprints: one line per value, footprint, MPN and DNP state;+/// `exclude_from_bom` footprints are left out, DNP parts stay as DNP lines.+pub fn write_bom(inv: &Inventory) -> String {+    let mut groups: BTreeMap<(String, String, String, String, bool), Vec<String>> = BTreeMap::new();+    for (r, p) in inv.parts.iter().filter(|(_, p)| p.in_bom) {+        groups.entry((p.value.clone(), p.footprint.clone(), p.manufacturer.clone(), p.mpn.clone(), p.dnp)).or_default().push(r.clone());+    }+    let mut lines: Vec<(Vec<String>, String)> = groups.into_iter().map(|((v, fp, m, mpn, dnp), mut refs)| {+        refs.sort_by_key(|a| natural(a));+        let line = [refs.join(" "), refs.len().to_string(), v, fp, m, mpn, if dnp { "DNP".into() } else { String::new() }].iter().map(|s| csv_field(s)).collect::<Vec<_>>().join(",");+        (refs, line)+    }).collect();+    lines.sort_by_key(|(refs, _)| natural(&refs[0]));+    let mut out = String::from("Reference,Qty,Value,Footprint,Manufacturer,MPN,DNP\n");+    for (_, l) in lines { out.push_str(&l); out.push('\n'); }+    out+}++fn col(h: &[String], names: &[&str]) -> Option<usize> {+    names.iter().find_map(|n| h.iter().position(|c| c.trim().eq_ignore_ascii_case(n)))+}++fn diff(want: &BTreeSet<String>, got: &BTreeSet<String>) -> (Vec<String>, Vec<String>) {+    let mut a: Vec<String> = want.difference(got).cloned().collect();+    let mut b: Vec<String> = got.difference(want).cloned().collect();+    a.sort_by_key(|x| natural(x)); b.sort_by_key(|x| natural(x));+    (a, b)+}++fn list(v: &[String]) -> String {+    if v.len() > 12 { format!("{} and {} more", v[..12].join(", "), v.len() - 12) } else { v.join(", ") }+}++/// Re-read the BOM against the board: every BOM footprint exactly once, its value and DNP state as placed.+pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) {+    let rows = read_csv(text);+    let mut problems = Vec::new();+    let Some(h) = rows.first() else { return (json!({"lines": 0}), vec!["BOM is empty".into()]) };+    let (Some(rc), qc, vc, dc) = (col(h, &["reference", "references", "designator", "refdes"]), col(h, &["qty", "quantity"]), col(h, &["value"]), col(h, &["dnp"])) else {+        return (json!({"lines": 0}), vec!["BOM has no Reference column".into()]);+    };+    let mut seen = BTreeSet::new();+    let mut dup = Vec::new();+    let (mut placed, mut dnp) = (0usize, 0usize);+    for row in &rows[1..] {+        let refs: Vec<String> = row.get(rc).map(|s| s.split([' ', ',', ';']).filter(|x| !x.is_empty()).map(str::to_string).collect()).unwrap_or_default();+        if let Some(q) = qc.and_then(|i| row.get(i)).and_then(|q| q.trim().parse::<usize>().ok()) {+            if q != refs.len() { problems.push(format!("BOM line {} says {q} but names {} part(s)", refs.join(" "), refs.len())); }+        }+        let is_dnp = dc.and_then(|i| row.get(i)).map(|s| !s.trim().is_empty()).unwrap_or(false);+        for r in refs {+            if let Some(p) = inv.parts.get(&r) {+                if let Some(v) = vc.and_then(|i| row.get(i)) { if *v != p.value { problems.push(format!("BOM gives {r} the value {v}, the board has {}", p.value)); } }+                if is_dnp != p.dnp { problems.push(format!("BOM marks {r} {}, the board {}", if is_dnp { "DNP" } else { "fitted" }, if p.dnp { "does not fit it (DNP)" } else { "fits it" })); }+            }+            if is_dnp { dnp += 1 } else { placed += 1 }+            if !seen.insert(r.clone()) { dup.push(r); }+        }+    }+    let want: BTreeSet<String> = inv.parts.iter().filter(|(_, p)| p.in_bom).map(|(r, _)| r.clone()).collect();+    let (missing, extra) = diff(&want, &seen);+    if !missing.is_empty() { problems.push(format!("BOM is missing {}", list(&missing))); }+    if !extra.is_empty() { problems.push(format!("BOM lists parts the board does not put in a BOM: {}", list(&extra))); }+    if !dup.is_empty() { problems.push(format!("BOM lists more than once: {}", list(&dup))); }+    (json!({"lines": rows.len() - 1, "refs": seen.len(), "fitted": placed, "dnp": dnp}), problems)+}++/// Re-read the pick and place: exactly the fitted, placeable parts, each on its own side, the top-side+/// rotations as placed. Bottom-side rotations follow kicad-cli's own convention and are only counted.+pub fn check_cpl(text: &str, inv: &Inventory) -> (Value, Vec<String>) {+    let rows = read_csv(text);+    let mut problems = Vec::new();+    let Some(h) = rows.first() else { return (json!({"refs": 0}), vec!["pick and place file is empty".into()]) };+    let Some(rc) = col(h, &["ref", "designator", "reference", "refdes"]) else { return (json!({"refs": 0}), vec!["pick and place has no Ref column".into()]) };+    let (rotc, sidec) = (col(h, &["rot", "rotation"]), col(h, &["side", "layer"]));+    let mut seen = BTreeSet::new();+    let mut dup = Vec::new();+    let (mut rot_checked, mut bottom_unchecked) = (0usize, 0usize);+    for row in &rows[1..] {+        let Some(r) = row.get(rc).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else { continue };+        if !seen.insert(r.clone()) { dup.push(r.clone()); }+        let Some(p) = inv.parts.get(&r) else { continue };+        if let Some(side) = sidec.and_then(|i| row.get(i)).map(|s| s.trim().to_ascii_lowercase()) {+            let bottom = side.starts_with("bot") || side == "b";+            if bottom != p.bottom { problems.push(format!("pick and place puts {r} on the {side}, the board has it on the {}", if p.bottom { "bottom" } else { "top" })); }+        }+        match rotc.and_then(|i| row.get(i)).map(|s| s.trim().parse::<f64>()) {+            Some(Ok(rot)) if !p.bottom => {+                let d = (rot - p.rot).rem_euclid(360.0);+                if d > 0.05 && d < 359.95 { problems.push(format!("pick and place rotates {r} {rot}, the board {}", p.rot)); }+                rot_checked += 1;+            }+            Some(Ok(_)) => bottom_unchecked += 1,+            Some(Err(_)) => problems.push(format!("pick and place rotation of {r} is not a number")),+            None => problems.push(format!("pick and place has no rotation for {r}")),+        }+    }+    let want: BTreeSet<String> = inv.parts.iter().filter(|(_, p)| p.in_pos && !p.dnp).map(|(r, _)| r.clone()).collect();+    let (missing, extra) = diff(&want, &seen);+    if !missing.is_empty() { problems.push(format!("pick and place is missing {}", list(&missing))); }+    if !extra.is_empty() { problems.push(format!("pick and place lists parts the fab must not place (DNP or excluded from position files): {}", list(&extra))); }+    if !dup.is_empty() { problems.push(format!("pick and place lists more than once: {}", list(&dup))); }+    (json!({"refs": seen.len(), "topRotationsChecked": rot_checked, "bottomRotationsNotChecked": bottom_unchecked}), problems)+}++/// A Gerber's X2 file function (`Copper,L1,Top`), from the attribute or KiCad's `G04 #@!` comment form.+pub fn gerber_function(text: &str) -> Option<String> {+    let i = text.find("TF.FileFunction,")?;+    let rest = &text[i + "TF.FileFunction,".len()..];+    Some(rest[..rest.find(['*', '\n']).unwrap_or(rest.len())].trim().to_string())+}++/// Re-read the Gerbers: one copper file per board copper layer (L1..Ln, nothing more), both masks,+/// both silkscreens and the outline.+pub fn check_gerbers(files: &[(String, String)], copper: usize) -> (Value, Vec<String>, Vec<String>) {+    let (mut problems, mut notes) = (Vec::new(), Vec::new());+    let mut layers = BTreeSet::new();+    let mut functions: Vec<String> = Vec::new();+    for (name, text) in files {+        let Some(f) = gerber_function(text) else { notes.push(format!("{name}: no X2 file function, layer not identified")); continue };+        let parts: Vec<&str> = f.split(',').collect();+        if parts.first() == Some(&"Copper") {+            if let Some(n) = parts.get(1).and_then(|l| l.strip_prefix('L')).and_then(|n| n.parse::<usize>().ok()) {+                if !layers.insert(n) { problems.push(format!("two Gerbers for copper layer L{n}")); }+                if !(text.contains("D01") || text.contains("D03") || text.contains("G36")) { notes.push(format!("copper layer L{n} ({name}) draws nothing")); }+            }+        }+        functions.push(f);+    }+    if copper == 0 { problems.push("the board declares no copper layers".into()); }+    let want: BTreeSet<usize> = (1..=copper).collect();+    let missing: Vec<String> = want.difference(&layers).map(|n| format!("L{n}")).collect();+    let extra: Vec<String> = layers.difference(&want).map(|n| format!("L{n}")).collect();+    if !missing.is_empty() { problems.push(format!("Gerbers lack copper layer(s) {} of the board's {copper}", missing.join(", "))); }+    if !extra.is_empty() { problems.push(format!("Gerbers carry copper layer(s) {} the {copper}-layer board does not have", extra.join(", "))); }+    for (what, prefix) in [("top solder mask", "Soldermask,Top"), ("bottom solder mask", "Soldermask,Bot"), ("top silkscreen", "Legend,Top"), ("bottom silkscreen", "Legend,Bot"), ("board outline", "Profile")] {+        if !functions.iter().any(|f| f.starts_with(prefix)) { problems.push(format!("no {what} Gerber")); }+    }+    (json!({"files": files.len(), "copperLayers": layers.len(), "boardCopperLayers": copper, "functions": functions}), problems, notes)+}++/// One Excellon file: plated or not (its file function, else its name), and its hits (one per+/// coordinate line: a round hole, or a slot in the G85 form kicad-cli writes with --excellon-oval-format alternate).+pub fn drill_file(name: &str, text: &str) -> (Option<bool>, usize) {+    let plated = gerber_function(text).map(|f| !f.starts_with("NonPlated")).or_else(|| {+        let n = name.to_ascii_uppercase();+        if n.contains("NPTH") { Some(false) } else if n.contains("PTH") { Some(true) } else { None }+    });+    let hits = text.lines().map(str::trim).filter(|l| l.starts_with('X') || l.starts_with('Y')).count();+    (plated, hits)+}++/// Re-read the drill files: every via and every drilled pad is one hit.+pub fn check_drill(files: &[(String, String)], inv: &Inventory) -> (Value, Vec<String>, Vec<String>) {+    let (mut problems, mut notes) = (Vec::new(), Vec::new());+    let (mut plated, mut npth, mut unknown) = (0usize, 0usize, 0usize);+    for (name, text) in files {+        match drill_file(name, text) { (Some(true), n) => plated += n, (Some(false), n) => npth += n, (None, n) => unknown += n }+    }+    let total = plated + npth + unknown;+    let want_plated = inv.vias + inv.plated_pad_holes;+    let want = want_plated + inv.npth_pad_holes;+    if files.is_empty() && want > 0 { problems.push(format!("no drill file, the board has {want} holes")); }+    else if total != want { problems.push(format!("drill files hold {total} hits, the board has {want} holes ({} vias, {} plated pad holes, {} unplated)", inv.vias, inv.plated_pad_holes, inv.npth_pad_holes)); }+    else if unknown == 0 && (plated != want_plated || npth != inv.npth_pad_holes) {+        notes.push(format!("drill split differs from the pad types: {plated} plated and {npth} unplated hits, the board {want_plated} and {}", inv.npth_pad_holes));+    }+    (json!({"files": files.len(), "hits": total, "platedHits": plated, "unplatedHits": npth, "vias": inv.vias, "platedPadHoles": inv.plated_pad_holes, "unplatedPadHoles": inv.npth_pad_holes}), problems, notes)+}++/// Bare test pads and machine parts the fab should not place: a hint, the attributes decide.+fn fab_role_notes(inv: &Inventory) -> Vec<String> {+    let tp = |r: &str, p: &Part| { let f = p.footprint.to_ascii_lowercase(); f.contains("testpoint") || f.contains("test_point") || f.contains("testpad") || (r.starts_with("TP") && r[2..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)) };+    let machine = |p: &Part| { let s = format!("{} {}", p.footprint, p.value).to_ascii_lowercase(); s.contains("machine") || s.contains("pressfit") || s.contains("press-fit") || s.contains("press_fit") };+    let tps: Vec<String> = inv.parts.iter().filter(|(r, p)| tp(r, p) && (p.in_pos || p.in_bom) && !p.dnp).map(|(r, _)| r.clone()).collect();+    let mach: Vec<String> = inv.parts.iter().filter(|(r, p)| !tp(r, p) && machine(p) && p.in_pos && !p.dnp).map(|(r, _)| r.clone()).collect();+    let mut out = Vec::new();+    if !tps.is_empty() { out.push(format!("test points still in the BOM or pick and place (bare copper: set Exclude from BOM and position files): {}", list(&tps))); }+    if !mach.is_empty() { out.push(format!("machine contacts or pins the fab would place (fitted after assembly: set DNP or exclude from position files): {}", list(&mach))); }+    out+}++fn read_dir(dir: &Path, exts: &[&str]) -> Vec<(String, String)> {+    let mut out: Vec<(String, String)> = std::fs::read_dir(dir).into_iter().flatten().flatten()+        .filter(|e| e.path().extension().and_then(|x| x.to_str()).map(|x| exts.iter().any(|w| x.eq_ignore_ascii_case(w))).unwrap_or(false))+        .filter_map(|e| Some((e.file_name().to_string_lossy().to_string(), std::fs::read_to_string(e.path()).ok()?))).collect();+    out.sort();+    out+}++fn kicad(cli: &str, args: &[String]) -> Result<(), String> {+    let o = std::process::Command::new(cli).args(args).output().map_err(|e| format!("{cli} is not runnable: {e}"))?;+    if o.status.success() { return Ok(()); }+    let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>() };+    Err(format!("{cli} {} failed: {} {}", args[..3.min(args.len())].join(" "), tail(&o.stdout), tail(&o.stderr)))+}++/// Write the set into `out` and check it. Ok: the proof (what was checked); Err: the report and the problems.+pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value, Vec<String>)> {+    let fail = |m: String| (json!({"board": board_path}), vec![m]);+    let board = Board::load(board_path).map_err(fail)?;+    let inv = inventory(&board);+    let stem = Path::new(board_path).file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();+    let gdir = out.join("gerbers");+    std::fs::create_dir_all(&gdir).map_err(|e| fail(format!("{}: {e}", gdir.display())))?;+    // a fresh set: last time's layer and drill files must not be counted as this board's+    for e in std::fs::read_dir(&gdir).into_iter().flatten().flatten() {+        if e.path().extension().and_then(|x| x.to_str()).map(|x| matches!(x, "gbr" | "gbrjob" | "drl")).unwrap_or(false) { let _ = std::fs::remove_file(e.path()); }+    }+    let gslash = format!("{}/", gdir.display());+    let mut layers = inv.copper_layers.clone();+    layers.extend(["F.Mask", "B.Mask", "F.Paste", "B.Paste", "F.Silkscreen", "B.Silkscreen", "Edge.Cuts"].map(str::to_string));+    let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();+    kicad(cli, &[s(&["pcb", "export", "gerbers", "--output", &gslash, "--layers", &layers.join(","), "--no-protel-ext"]), vec![board_path.to_string()]].concat()).map_err(fail)?;+    kicad(cli, &[s(&["pcb", "export", "drill", "--output", &gslash, "--format", "excellon", "--excellon-units", "mm", "--excellon-separate-th", "--excellon-oval-format", "alternate"]), vec![board_path.to_string()]].concat()).map_err(fail)?;+    let cpl = out.join(format!("{stem}-cpl.csv"));+    let _ = std::fs::remove_file(&cpl);+    kicad(cli, &[s(&["pcb", "export", "pos", "--output", &cpl.display().to_string(), "--format", "csv", "--units", "mm", "--side", "both", "--exclude-dnp"]), vec![board_path.to_string()]].concat()).map_err(fail)?;+    let bom = out.join(format!("{stem}-bom.csv"));+    std::fs::write(&bom, write_bom(&inv)).map_err(|e| fail(format!("{}: {e}", bom.display())))?;++    // read every file back, as the fab will+    let gerbers = read_dir(&gdir, &["gbr"]);+    let drills = read_dir(&gdir, &["drl"]);+    let (g, mut problems, mut notes) = check_gerbers(&gerbers, inv.copper_layers.len());+    let (d, p2, n2) = check_drill(&drills, &inv);+    problems.extend(p2); notes.extend(n2);+    let (c, p3) = match std::fs::read_to_string(&cpl) { Ok(t) => check_cpl(&t, &inv), Err(e) => (Value::Null, vec![format!("pick and place not written ({}: {e})", cpl.display())]) };+    problems.extend(p3);+    let (b, p4) = check_bom(&std::fs::read_to_string(&bom).unwrap_or_default(), &inv);+    problems.extend(p4);+    notes.extend(fab_role_notes(&inv));+    let no_mpn: Vec<String> = inv.parts.iter().filter(|(_, p)| p.in_bom && !p.dnp && p.mpn.is_empty()).map(|(r, _)| r.clone()).collect();+    if !no_mpn.is_empty() { notes.push(format!("parts without an MPN property in the BOM: {}", list(&no_mpn))); }+    let mut files: Vec<String> = gerbers.iter().chain(drills.iter()).map(|(n, _)| gdir.join(n).display().to_string()).collect();+    files.push(cpl.display().to_string()); files.push(bom.display().to_string());+    let report = json!({+        "pass": problems.is_empty(), "dir": out.display().to_string(), "board": board_path,+        "boardSha256": crate::sha256_file(board_path), "kicadCli": cli,+        "gerbers": g, "drill": d, "cpl": c, "bom": b, "bomSource": "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)",+        "files": files, "notes": notes, "problems": problems,+    });+    if problems.is_empty() { Ok(report) } else { Err((report, problems)) }+}++#[cfg(test)]+mod tests {+    use super::*;++    const BOARD: &str = r#"(kicad_pcb (version 20240108) (generator "pcbnew")+  (layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "In2.Cu" power) (31 "B.Cu" signal) (36 "B.SilkS" user "B.Silkscreen") (44 "Edge.Cuts" user))+  (net 0 "") (net 1 "GND")+  (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (width 0.1))+  (footprint "Resistor_SMD:R_0603" (layer "F.Cu") (at 5 5 90)+    (property "Reference" "R1") (property "Value" "10k") (property "MPN" "RC0603FR-0710KL") (attr smd)+    (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu" "F.Paste" "F.Mask")))+  (footprint "Resistor_SMD:R_0603" (layer "B.Cu") (at 8 5 180)+    (property "Reference" "R2") (property "Value" "10k") (property "MPN" "RC0603FR-0710KL") (attr smd)+    (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "B.Cu" "B.Paste" "B.Mask")))+  (footprint "Connector:Header_1x02" (layer "F.Cu") (at 10 10)+    (property "Reference" "J1") (property "Value" "HDR") (attr through_hole)+    (pad "1" thru_hole circle (at 0 0) (size 1.7 1.7) (drill 1.0) (layers "*.Cu" "*.Mask"))+    (pad "2" thru_hole oval (at 2.54 0) (size 1.7 1.7) (drill oval 1.0 1.4) (layers "*.Cu" "*.Mask")))+  (footprint "Adom:MachinePin" (layer "F.Cu") (at 1 1)+    (property "Reference" "MP1") (property "Value" "machine pin") (attr through_hole dnp)+    (pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 1.2) (layers "*.Cu" "*.Mask")))+  (footprint "Adom:TestPoint_Pad_0.5mm" (layer "F.Cu") (at 15 15)+    (property "Reference" "TP1") (property "Value" "TP") (attr smd exclude_from_pos_files exclude_from_bom)+    (pad "1" smd circle (at 0 0) (size 0.5 0.5) (layers "F.Cu" "F.Mask")))+  (footprint "MountingHole:MountingHole_3.2mm" (layer "F.Cu") (at 18 18)+    (property "Reference" "H1") (property "Value" "MountingHole") (attr exclude_from_pos_files exclude_from_bom)+    (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu" "*.Mask")))+  (via (at 3 3) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1))+  (via (at 4 3) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1))+)"#;++    fn inv() -> Inventory { inventory(&Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap()) }++    fn gerber(f: &str) -> String { format!("%TF.FileFunction,{f}*%\n%FSLAX46Y46*%\nX0Y0D02*\nX1Y1D01*\nM02*\n") }++    #[test]+    fn inventory_reads_attributes_layers_and_holes() {+        let i = inv();+        assert_eq!(i.copper_layers.len(), 4);+        assert_eq!((i.vias, i.plated_pad_holes, i.npth_pad_holes), (2, 3, 1));+        assert!(i.parts["MP1"].dnp && !i.parts["TP1"].in_bom && !i.parts["TP1"].in_pos);+        assert!(i.parts["R2"].bottom && i.parts["R1"].rot == 90.0);+        assert_eq!(i.parts["R1"].mpn, "RC0603FR-0710KL");+    }++    #[test]+    fn gerbers_must_match_the_copper_layer_count() {+        let mut files: Vec<(String, String)> = ["Copper,L1,Top", "Copper,L2,Inr", "Copper,L3,Inr", "Copper,L4,Bot", "Soldermask,Top", "Soldermask,Bot", "Legend,Top", "Legend,Bot", "Profile,NP"]+            .iter().enumerate().map(|(k, f)| (format!("b-{k}.gbr"), gerber(f))).collect();+        let (g, p, _) = check_gerbers(&files, 4);+        assert!(p.is_empty(), "{p:?}");+        assert_eq!(g["copperLayers"], 4);+        files.remove(2);+        let (_, p, _) = check_gerbers(&files, 4);+        assert!(p.iter().any(|x| x.contains("L3")), "{p:?}");+        let (_, p, _) = check_gerbers(&files, 2);+        assert!(p.iter().any(|x| x.contains("L4")), "a 2-layer board's set must not carry L4: {p:?}");+        // KiCad's comment form of the attribute counts too+        assert_eq!(gerber_function("G04 #@! TF.FileFunction,Copper,L1,Top*\n").as_deref(), Some("Copper,L1,Top"));+    }++    #[test]+    fn drill_hits_must_match_vias_and_drilled_pads() {+        let pth = "M48\n; #@! TF.FileFunction,Plated,1,4,PTH\nMETRIC\nT1C0.300\nT2C1.000\n%\nG90\nT1\nX3.0Y-3.0\nX4.0Y-3.0\nT2\nX10.0Y-10.0\nX1.0Y-1.0\nX12.54Y-10.0G85X12.54Y-10.4\nM30\n";+        let npth = "M48\n; #@! TF.FileFunction,NonPlated,1,4,NPTH\nMETRIC\nT1C3.200\n%\nT1\nX18.0Y-18.0\nM30\n";+        assert_eq!(drill_file("b-PTH.drl", pth), (Some(true), 5));+        let files = vec![("b-NPTH.drl".to_string(), npth.to_string()), ("b-PTH.drl".to_string(), pth.to_string())];+        let (d, p, n) = check_drill(&files, &inv());+        assert!(p.is_empty() && n.is_empty(), "{p:?} {n:?}");+        assert_eq!(d["hits"], 6);+        let (_, p, _) = check_drill(&files[1..], &inv());+        assert!(p[0].contains("5 hits") && p[0].contains("6 holes"), "{p:?}");+    }++    #[test]+    fn cpl_must_name_the_fitted_parts_on_their_sides() {+        let good = "Ref,Val,Package,PosX,PosY,Rot,Side\n\"J1\",\"HDR\",\"Header_1x02\",10,-10,0,top\n\"R1\",\"10k\",\"R_0603\",5,-5,90.000000,top\n\"R2\",\"10k\",\"R_0603\",8,-5,180,bottom\n";+        let (c, p) = check_cpl(good, &inv());+        assert!(p.is_empty(), "{p:?}");+        assert_eq!((c["topRotationsChecked"].as_u64(), c["bottomRotationsNotChecked"].as_u64()), (Some(2), Some(1)));+        // -270 is 90+        assert!(check_cpl(&good.replace("90.000000", "-270"), &inv()).1.is_empty());+        let bad = "Ref,Val,Package,PosX,PosY,Rot,Side\nR1,10k,R_0603,5,-5,0,top\nR2,10k,R_0603,8,-5,180,top\nMP1,pin,x,1,1,0,top\n";+        let p = check_cpl(bad, &inv()).1.join("; ");+        assert!(p.contains("rotates R1") && p.contains("R2 on the top") && p.contains("missing J1") && p.contains("must not place") && p.contains("MP1"), "{p}");+    }++    #[test]+    fn bom_round_trips_against_the_board() {+        let i = inv();+        let text = write_bom(&i);+        let (b, p) = check_bom(&text, &i);+        assert!(p.is_empty(), "{p:?}\n{text}");+        assert_eq!((b["fitted"].as_u64(), b["dnp"].as_u64()), (Some(3), Some(1)));+        assert!(text.contains("R1 R2,2,10k") && text.contains(",DNP"), "{text}");+        assert!(!text.contains("TP1") && !text.contains("H1"));+        let broken = text.replace("R1 R2,2,10k", "R1,2,10k");+        let p = check_bom(&broken, &i).1.join("; ");+        assert!(p.contains("says 2 but names 1") && p.contains("missing R2"), "{p}");+        let p = check_bom(&text.replace(",DNP", ","), &i).1.join("; ");+        assert!(p.contains("marks MP1 fitted"), "{p}");+    }++    #[test]+    fn csv_reader_handles_quotes() {+        let rows = read_csv("a,\"b,c\",\"d \"\"e\"\"\"\r\n\n1,2,3");+        assert_eq!(rows, vec![vec!["a", "b,c", "d \"e\""], vec!["1", "2", "3"]]);+    }++    /// The whole command with a stand-in kicad-cli that writes what the native one would: proof on a+    /// matching set, refusal (and no proof) when a layer is missing.+    #[cfg(unix)]+    #[test]+    fn export_runs_kicad_cli_and_refuses_a_set_that_does_not_match() {+        use std::os::unix::fs::PermissionsExt;+        let dir = std::env::temp_dir().join(format!("aiflow-export-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let board = dir.join("demo.kicad_pcb");+        std::fs::write(&board, BOARD).unwrap();+        let fake = |skip: &str| {+            let script = format!(r#"#!/bin/sh+out=""; kind="$3"; prev=""+for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; done+g() {{ printf '%%TF.FileFunction,%s*%%\nX0Y0D02*\nX1Y1D01*\nM02*\n' "$2" > "$out/$1"; }}+case "$kind" in+gerbers) for f in "F_Cu.gbr:Copper,L1,Top" "In1_Cu.gbr:Copper,L2,Inr" "In2_Cu.gbr:Copper,L3,Inr" "B_Cu.gbr:Copper,L4,Bot" "F_Mask.gbr:Soldermask,Top" "B_Mask.gbr:Soldermask,Bot" "F_Silkscreen.gbr:Legend,Top" "B_Silkscreen.gbr:Legend,Bot" "Edge_Cuts.gbr:Profile,NP"; do+  n="${{f%%:*}}"; [ "$n" = "{skip}" ] || g "demo-$n" "${{f#*:}}"; done;;+drill) printf 'M48\nT1C0.3\n%%\nT1\nX3Y-3\nX4Y-3\nX10Y-10\nX1Y-1\nX12Y-10G85X12Y-10.4\nM30\n' > "$out/demo-PTH.drl"; printf 'M48\nT1C3.2\n%%\nT1\nX18Y-18\nM30\n' > "$out/demo-NPTH.drl";;+pos) printf 'Ref,Val,Package,PosX,PosY,Rot,Side\nJ1,HDR,H,10,-10,0,top\nR1,10k,R,5,-5,90,top\nR2,10k,R,8,-5,180,bottom\n' > "$out";;+esac+"#);+            let p = dir.join("kicad-cli");+            std::fs::write(&p, script).unwrap();+            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();+            p.display().to_string()+        };+        let cli = fake("none");+        let rep = export(board.to_str().unwrap(), &dir.join("mfg"), &cli).unwrap_or_else(|(r, p)| panic!("{p:?} {r}"));+        assert_eq!(rep["pass"], true);+        assert_eq!(rep["gerbers"]["copperLayers"], 4);+        assert_eq!(rep["drill"]["hits"], 6);+        assert_eq!(rep["cpl"]["refs"], 3);+        assert!(dir.join("mfg/demo-bom.csv").is_file());+        let cli = fake("In2_Cu.gbr");+        let (_, problems) = export(board.to_str().unwrap(), &dir.join("mfg"), &cli).unwrap_err();+        assert!(problems.iter().any(|p| p.contains("L3")), "a stale In2 file from the last export must not count: {problems:?}");+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/main.rs+261−50
@@ -15,6 +15,8 @@ mod browser_access; mod schematic_identity; mod placement_verify; mod provenance;+mod export;+mod silkproof;  /// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html");@@ -63,7 +65,10 @@ enum Cmd {     Exec { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] command: Vec<String> },     SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },     SilkscreenDashboard { action: String, #[arg(long, default_value="auto")] port: String, #[arg(long, default_value="wv")] surface: String, #[arg(long)] url: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] json: Option<String>, #[arg(long)] reason: Option<String> },-    SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+    /// Whole-board silkscreen obstacle audit (fail-closed). With --record, a pass whose inventory covers the board (every footprint reference, a via/hole item per via) and whose labels meet finish's per-footprint minimum records the `silkscreen` step proof; a failed audit records nothing.+    SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, /// record the silkscreen step proof when the audit passes and covers the board+    #[arg(long)] record: bool, /// the board the inventory covers (default: the run's current board)+    #[arg(long)] board: Option<String> },     SilkscreenSections { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },@@ -84,7 +89,7 @@ enum Cmd {     #[arg(long)] net: Option<String> },     /// `molecule check`: Adom molecule conformance (MP1 to MP4 machine pins, contacts on the grid from MP1, edge margin, fixed references) from spec.molecule; `place check`, `gate` and `finish` run it too when the spec has "molecule".     Molecule { #[arg(value_parser = ["check"])] action: String, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },-    /// `rules use --profile 3rdPartyFab|<slug>`: the design-rules step. Loads a fab's rule pack from the wiki page adom/pcb-design-rules (3rdPartyFab is the default; `rules list` shows them all): the KiCad .kicad_dru beside the board and in the run, the Fusion DRC rules into the open design, the choice into the spec and the run. `rules install --profile jlcpcb`: put the shipped JLCPCB 2-layer .kicad_dru beside the board; `rules check`: does the board's project carry the rules profile spec.fab.rules names ("fab" = the project's own .kicad_dru, "jlcpcb" = the shipped profile).+    /// `rules use --profile 3rdPartyFab|<slug>`: the design-rules step. Loads a fab's rule pack from the wiki page adom/pcb-design-rules (3rdPartyFab is the default; `rules list` shows them all): the KiCad .kicad_dru beside the board and in the run, the Fusion DRC rules into the open design, the choice into the spec and the run. `rules install --profile jlcpcb --layers 2`: put the shipped JLCPCB 2-layer .kicad_dru beside the board (the only shipped pack; other layer counts come from `rules use`); `rules check`: does the board's project carry the rules profile spec.fab.rules names, else run.json fabRules ("fab" = the project's own .kicad_dru, "jlcpcb" = the JLCPCB pack for the board's copper layer count, "jlcpcb-6L" = that count exactly).     /// `testplan check --tests tests.json`: the test-plan step. Every measurement the AI plans for the probing workcell gets a probe spot: before the board, a test point on its net in the netlist (`--netlist <netlist.json> --add` adds TP<n> for each gap, 0.5 mm round pads from adom/testpoint-pad-0-5mm); with a board, a real 0.5 mm landing (test point or exposed pad) clear of part bodies and other nets, within 50 mm of a ground contact. finish runs the board check again.     /// `thermal-vias plan`: the heatsink-via step. For every part in spec.hot ({REF: {watts, tabNet}}) a via target from its dissipation and the rise budget (at least 6), and the spots beside its heat pads, clear of other nets, the edge and every pad; existing vias count. `thermal-vias land` lands the plan one via at a time; `--ack REF --why` accepts a part short of its target. thermal and finish refuse a hot part below target.     ThermalVias { #[arg(value_parser = ["plan", "land"])] action: String, #[arg(long)] ack: Option<String>, #[arg(long)] why: Option<String>, #[arg(long, default_value_t = 400)] pause_ms: u64 },@@ -94,8 +99,8 @@ enum Cmd {     Timeline { #[arg(value_parser = ["open", "serve", "write", "present"])] action: String, #[arg(long, default_value_t = 8893)] port: u16, #[arg(long)] json: Option<String> },     Testplan { #[arg(value_parser = ["check"])] action: String, #[arg(long)] tests: Option<String>, #[arg(long)] netlist: Option<String>, #[arg(long)] add: bool },     Rules { #[arg(value_parser = ["use", "install", "check", "list"])] action: String, #[arg(long)] profile: Option<String>, #[arg(long)] board: Option<String>, /// replace an existing .kicad_dru-    #[arg(long)] force: bool, /// copper layers (default 2)-    #[arg(long, default_value_t = 2)] layers: u32 },+    #[arg(long)] force: bool, /// copper layers (default: the profile's -<n>L suffix, else the board's copper layer count, else 2)+    #[arg(long)] layers: Option<u32> },     /// Start the clock at the prompt before any board exists: the run opens with the `intake` step and no board; `start --board` attaches it later on the same clock.     Intake { #[arg(long)] prompt_time: String, /// the brief (a file: the human's message, a transcript, a spec); a requirements JSON is recorded as the requirements too     #[arg(long)] brief: Option<String>, /// requirements.json (aiflow-intake): fab.target picks the sourcing profile@@ -131,6 +136,10 @@ enum Cmd {     Pour { #[arg(long)] plan: Option<String> },     /// The Fusion side of a project: `library` (one project library with real 3D and part numbers), `reference` (placement and spec from a reference KiCad board, in the Fusion board's frame), `schematic` (the design started with the fab's rules, the schematic drawn live in groups), `export` (Gerbers, pick and place, BOM). `adom-aiflow fusion <step> --help` for each.     Fusion { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },+    /// The KiCad lane's export step: Gerbers, drill, pick and place (CPL) and BOM written from the board with the native kicad-cli (ADOM_AIFLOW_KICAD_CLI, else kicad-cli; adom-aiflow-kicad-cli-remote in a container), then re-read against the board (copper layer count, drill hits against vias and drilled pads, CPL and BOM references, sides and top rotations). Records the `export` proof only when every file matches. Fusion: `fusion export`.+    Export { /// the board (default: the run's current board)+    #[arg(long)] board: Option<String>, /// where the set goes (default: <run>/manufacturing)+    #[arg(long)] out: Option<PathBuf> },     /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay     Unroute { #[arg(long, required = true)] net: Vec<String> },     /// KiCad DRC offline on the routing plan@@ -851,6 +860,31 @@ fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option< }  +/// The tools every clip check and cut runs. Missing ones are an error naming the tool, never a verdict+/// on the footage (issue #48: with no ffprobe every clip read as "no frames" and compose dropped them all).+const MEDIA_TOOLS: &[&str] = &["ffprobe", "ffmpeg"];++/// Err names the first tool of `tools` that cannot be started.+fn media_tools_missing(tools: &[&str]) -> Result<(), String> {+    for t in tools {+        if let Err(e) = std::process::Command::new(t).arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status() {+            return Err(format!("{t} is not installed or not runnable ({e}); the clips cannot be checked, cut or composed without it"));+        }+    }+    Ok(())+}++/// The clip check: Err when the media tools are missing (the footage is not judged), else the blank verdict.+fn clip_verdict(file: &str, tools: &[&str]) -> Result<Option<String>, String> {+    media_tools_missing(tools)?;+    Ok(blank_check(file))+}++/// Verdicts that only the blank check gives; a later passing check (recut) clears them. An AI's own rejection stays.+fn blank_check_verdict(why: &str) -> bool {+    why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour")+}+ /// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a /// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey /// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than@@ -916,7 +950,18 @@ fn media_seconds(path: &str) -> f64 {  /// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts /// of the step. The window recorder only emits frames on change, so the cut is by timestamp.-fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {+fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) { clip_artifacts_with(r, step, file, speed, MEDIA_TOOLS) }++fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools: &[&str]) {+    // no ffprobe/ffmpeg: say so and keep the clip as it is, unjudged (never flag the footage blank)+    let verdict = match clip_verdict(file, tools) {+        Ok(v) => v,+        Err(e) => {+            eprintln!("ERROR clip {} not checked or cut: {e}. Install ffmpeg (it brings ffprobe), then `adom-aiflow recut`.", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));+            r.log("clip-check-unavailable", json!({"step": step, "file": file, "why": e}));+            return;+        }+    };     let dir = r.dir.clone();     let tag = step.as_str().unwrap_or("step").to_string();     // the clip's offset from the prompt, so the overlay can show the run's own clock@@ -929,7 +974,16 @@ fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {     let secs = media_seconds(file);     // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an     // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud-    let verdict = blank_check(file);+    // a recut that now sees frames clears a blank verdict an earlier check gave (a run on a box without ffprobe)+    if verdict.is_none() {+        if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) {+            if c["suspect"].as_str().map(blank_check_verdict).unwrap_or(false) {+                let was = c["suspect"].clone();+                c.as_object_mut().map(|o| o.remove("suspect"));+                r.log("clip-suspect-cleared", json!({"step": step, "file": file, "was": was}));+            }+        }+    }     if let Some(why) = &verdict {         eprintln!("WARNING clip {} looks blank: {why}", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));         if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["suspect"] = json!(why); }@@ -2007,10 +2061,13 @@ fn run_sequence(r: &Run, flow: &Value) -> Vec<String> {     if seq.is_empty() { sequence::sequence_of(flow, sequence::lane(&r.dir, &r.data)) } else { seq } } -/// The over-budget nag for the current step (warn only, never a block).+/// The over-budget nag for the current step (warn only, never a block). Held while an `exec` of this+/// run is still running: a long solve is the step's own work, not a step left undeclared. fn overdue_warning(r: &Run) -> Option<String> {     let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-    sequence::overdue_warning(&r.data, &flow, &run_sequence(r, &flow), aiflow_run::parse(&now()).unwrap_or(0))+    let w = sequence::overdue_warning(&r.data, &flow, &run_sequence(r, &flow), aiflow_run::parse(&now()).unwrap_or(0))?;+    if sequence::open_exec(&r.ledger(), &sequence::pid_alive).is_some() { return None; }+    Some(w) }  /// The NEXT line: the exact command for the next required step, with its one-line description.@@ -2039,6 +2096,22 @@ fn continuation_report(r: &Run, evidence: Option<&Path>) -> Value {     continuation::assess(&r.data, &pending, stop) } +/// A routing plan read and normalised the one way both `gate` and `land` see it (issue #33):+/// repeated waypoints and paths with no length are dropped; the notes name each drop.+fn read_plan_normalised(path: &str, b: &Board) -> (Value, Vec<String>) {+    // `{"nets": [...]}` or a bare array of entries: both become the object form first (issue #47)+    let mut v = aiflow_copper::normalize_plan(read_json(path));+    if let Err(e) = aiflow_copper::plan_entries(&v) { err(&format!("{path}: {e}"), &[]); }+    let notes = aiflow_copper::normalise_plan(b, &mut v);+    (v, notes)+}++fn print_plan_notes(notes: &[String]) {+    if notes.is_empty() { return; }+    eprintln!("Note: the plan was normalised before use ({} change(s)); the gate and land route see the same copper:", notes.len());+    for n in notes { eprintln!("  {n}"); }+}+ fn main() {     let cli = Cli::parse();     // A host stop hook must not open windows, start captures or append timing events.@@ -2100,19 +2173,21 @@ fn main() {         }         Cmd::Exec { command } => {             thread(&cli); let mut r=load_run(&cli);-            r.log("external-start",json!({"program":command[0],"args":&command[1..]}));+            // an id and this process's pid: other commands see a running exec (the overdue warning waits for it)+            let exec_id=format!("{}-{}",std::process::id(),now());+            r.log("external-start",json!({"program":command[0],"args":&command[1..],"execId":exec_id,"pid":std::process::id()}));             r.save().unwrap();             let status=std::process::Command::new(&command[0]).args(&command[1..]).status();             let mut r=load_run(&cli);             match status {                 Ok(status)=>{-                    r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success()}));+                    r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success(),"execId":exec_id}));                     if status.success() && provenance::is_fields_solve(command) { if let Some(f)=provenance::fields_out(command) { record_fields_solve(&mut r, Path::new(&f), "exec", command); } }                     r.save().unwrap();                     if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}                     ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);                 }-                Err(e)=>err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()])+                Err(e)=>{ r.log("external-end",json!({"program":command[0],"success":false,"error":e.to_string(),"execId":exec_id})); r.save().unwrap(); err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()]) }             }         }         Cmd::SilkscreenSections { input, out } => {@@ -2165,11 +2240,57 @@ fn main() {             if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}             ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);         }-        Cmd::SilkscreenAudit { input, out } => {-            thread(&cli); let _r = load_run(&cli);+        Cmd::SilkscreenAudit { input, out, record, board } => {+            thread(&cli); let mut r = load_run(&cli);             let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));             if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }-            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);+            if *record {+                // the step proof: this audit passed, on an inventory that covers the board it claims to audit+                let board_path = board.clone().unwrap_or_else(|| r.current_board());+                let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+                let audit = read_json(&out.display().to_string());+                let data = read_json(&input.display().to_string());+                let mut proof = silkproof::proof(&audit, &data, &b).unwrap_or_else(|why| {+                    r.log("silkscreen-proof-refused", json!({"board": board_path, "audit": out, "why": why}));+                    err(&format!("silkscreen proof not recorded: {}", why.join("; ")), &["Build the inventory from the whole board (every footprint by its reference, every via and hole on both faces) and fix the labels, then `silkscreen-audit --record` again.".into()])+                });+                proof["board"] = json!(board_path);+                proof["boardSha256"] = json!(sha256_file(&board_path));+                proof["audit"] = json!(out.display().to_string());+                proof["auditSha256"] = json!(sha256_file(&out.display().to_string()));+                proof["input"] = json!(input.display().to_string());+                proof["inputSha256"] = json!(sha256_file(&input.display().to_string()));+                r.outcome("silkscreen", proof.clone());+                r.save().unwrap();+                ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"]), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]);+                return;+            }+            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into()]);+        }+        Cmd::Export { board, out } => {+            thread(&cli); let mut r = load_run(&cli);+            if sequence::lane(&dir, &r.data) == "fusion" { err("this run is on the Fusion lane", &["`adom-aiflow fusion export` writes and checks the set from the Fusion board and records the same proof.".into()]); }+            let board_path = board.clone().unwrap_or_else(|| r.current_board());+            let out_dir = out.clone().unwrap_or_else(|| dir.join("manufacturing"));+            let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+            match export::export(&board_path, &out_dir, &kicad) {+                Ok(report) => {+                    write_json(&out_dir.join("export-check.json"), &report);+                    r.outcome("export", report.clone());+                    r.save().unwrap();+                    let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect();+                    hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string));+                    hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());+                    ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"]), &hints);+                }+                Err((report, problems)) => {+                    let _ = std::fs::create_dir_all(&out_dir);+                    write_json(&out_dir.join("export-check.json"), &report);+                    r.log("export-refused", json!({"board": board_path, "dir": out_dir, "problems": problems}));+                    r.save().unwrap();+                    err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &["Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".into()]);+                }+            }         }         Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {             let t = thread(&cli); let _r = load_run(&cli);@@ -2241,7 +2362,14 @@ fn main() {                 r.save().unwrap();                 let _ = TURN.set((dir.clone(), idx));                 let steps: Vec<String> = r.data["steps"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();-                ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &[differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()]);+                let mut attach_hints = vec![differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()];+                // `rules use` in the intake run recorded fabRules before there was a spec: the gate+                // falls back to it when the spec names no fab.rules, and says when they disagree+                if let Some((p, layers, warning)) = checks::rules_target(&read_json(spec), &r.data["fabRules"]) {+                    if let Some(w) = warning { attach_hints.push(format!("Fab rules: {w}.")); }+                    if let Err((e, hint)) = checks::rules_gate(&p, &copy.display().to_string(), layers) { attach_hints.push(format!("Fab rules: {e}. {hint}")); }+                }+                ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &attach_hints);                 print_next(&cli);                 return;             }@@ -2258,7 +2386,7 @@ fn main() {             if sch.is_some() { start_hints.push("The run has a schematic: `adom-aiflow netlist check` proves the board equals it pin by pin; finish requires it.".into()); }             else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }             let spec_v = read_json(spec);-            if let Some(p) = checks::rules_profile(&spec_v) { if let Err(e) = checks::rules_check(&p, board) { start_hints.push(format!("Fab rules: {e}. {}", checks::rules_hint(&p))); } }+            if let Some((p, layers, _)) = checks::rules_target(&spec_v, &Value::Null) { if let Err((e, hint)) = checks::rules_gate(&p, board, layers) { start_hints.push(format!("Fab rules: {e}. {hint}")); } }             if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }             start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);@@ -2772,10 +2900,13 @@ fn main() {                 eprintln!("Hint: no routing plan; the board carries copper, so it is gated as it is with an empty plan ({})", p.display());                 p.display().to_string()             });-            let plan_v = read_json(&planp);+            // the same normalisation `land route` applies, so the gate passes only what lands (issue #33)+            let (plan_v, plan_notes) = read_plan_normalised(&planp, &b);+            print_plan_notes(&plan_notes);             let spec_g = spec_of(&r);-            if let Some(p) = checks::rules_profile(&spec_g) {-                if let Err(e) = checks::rules_check(&p, &r.current_board()) { err(&format!("gate refused: {e}"), &[checks::rules_hint(&p), "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }+            if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) {+                if let Some(w) = warning { eprintln!("Warning: {w}"); }+                if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { err(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }             }             if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {                 if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }@@ -2804,11 +2935,12 @@ fn main() {             let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); switchnode::findings(&spec_g, &rb) };             s["switchNodeIssues"] = json!(sw_issues);             if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }+            if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }             write_json(&dir.join("gate.json"), &s);             let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));             let npu = pu_issues.len() as u64;             r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);-            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "routedBoard": routed.display().to_string(), "plan": planp}));+            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));             r.save().unwrap();             if !sw_issues.is_empty() {                 r.outcome("switch-node", json!({"issues": sw_issues}));@@ -2852,7 +2984,8 @@ fn main() {                 let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);                 let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0;                 let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));-                if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && read_json(p)["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false)) {+                let empty_after_normalising = |p: &String| { let (v, _) = read_plan_normalised(p, &board_of(&r)); v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) };+                if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && empty_after_normalising(p)) {                     let note = read_json(&planp).get("note").cloned().unwrap_or(json!("empty plan"));                     r.stage_start("land-route", Some("binary"), None);                     r.stage_end("land-route", Some(json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0})), Some("empty plan: nothing to land"));@@ -2894,15 +3027,19 @@ fn main() {                     } else {                         plan.clone().or_else(|| r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))                     };-                    let entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();+                    // the gate's normalisation (issue #33): no zero-length trace reaches the bridge, and+                    // trace numbers (`--from`) count the same traces the gate passed+                    let b = board_of(&r);+                    let (plan_main, plan_notes) = read_plan_normalised(&planp, &b);+                    print_plan_notes(&plan_notes);+                    let entries: Vec<Value> = aiflow_copper::plan_entries(&plan_main).unwrap_or_else(|e| err(&format!("{planp}: {e}"), &[]));                     // a via must not touch another net's copper, planned or already on the board: KiCad                     // hands a touching via the other net and the bridge's DRC then sees nothing wrong-                    let b = board_of(&r);                     let pad_copper: Vec<Copper> = b.pads.iter().flat_map(|p| p.polys.iter().map(move |poly| Copper::Poly { poly: poly.clone(), net: p.net.clone(), what: format!("pad {} ({})", p.key, if p.net.is_empty() { "no net" } else { p.net.as_str() }) })).collect();-                    let mut plans: Vec<Value> = vec![read_json(&planp)];+                    let mut plans: Vec<Value> = vec![plan_main.clone()];                     if what == "vias" {                         if let Some(gp) = r.outcomes().get("gate").and_then(|g| g.get("plan")).or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan"))).and_then(|p| p.as_str()) {-                            if Path::new(gp).is_file() { plans.push(read_json(gp)); }+                            if Path::new(gp).is_file() { plans.push(read_plan_normalised(gp, &b).0); }                         }                     }                     let mut copper_cache: Option<Vec<Copper>> = None;@@ -2967,7 +3104,7 @@ fn main() {                         if rep["errorCode"] == "via_touches_other_net" {                             err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);                         }-                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped (or start at `--from {}`). The log has every reply.", i + 1)]);+                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped. To resume at this trace: `adom-aiflow land {what}{} --from {}`. The log has every reply.", plan.as_ref().map(|p| format!(" --plan {p}")).unwrap_or_default(), i + 1)]);                     }                     let skipped_note = if res.skipped > 0 { format!(", {} already on the board skipped", res.skipped) } else { String::new() };                     ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries{skipped_note}, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);@@ -3171,8 +3308,9 @@ fn main() {             }             if let Err(m) = netlist_gate(&r) { missing.push(m); }             let spec_f = spec_of(&r);-            if let Some(p) = checks::rules_profile(&spec_f).or_else(|| r.data["fabRules"]["slug"].as_str().map(str::to_string)) {-                if let Err(e) = checks::rules_check(&p, &r.current_board()) { missing.push(format!("fab rules ({e})")); }+            if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) {+                if let Some(w) = warning { eprintln!("Warning: {w}"); }+                if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { missing.push(format!("fab rules ({e}; {hint})")); }             }             if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {                 if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }@@ -3397,6 +3535,7 @@ fn main() {          Cmd::Recut => {             let t = thread(&cli);+            if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then recut again. No clip was judged.".into()]); }             let mut r = load_run(&cli);             // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder             if let Some(target) = r.data["target"].as_str().map(str::to_string) {@@ -3420,6 +3559,7 @@ fn main() {         }         Cmd::Compose { out, max_seconds, still_seconds, with, script } => {             thread(&cli);+            if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }             let mut r = load_run(&cli);             if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }             let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };@@ -3623,8 +3763,13 @@ fn main() {                 let (parts, plan) = thermalvias::plan_with_acknowledgements(&spec, &b, &sw, &acked);                 let pp = dir.join("plan-thermal-vias.json");                 write_json(&pp, &plan);-                let lines: Vec<String> = parts.iter().map(|p| format!("  {:<5} {:<5} target {:>2}  existing {:>2}  planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!("  ({n})")).unwrap_or_default())).collect();-                r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note})).collect::<Vec<_>>()}));+                // issue #40: spec.pours against the board's zones; a stale outline makes the counts wrong+                let pours_landed = r.outcomes().get("land-pours").map(|p| p["pass"] == true || p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false);+                let freshness = thermalvias::pour_freshness(&spec, &b, pours_landed);+                for f in freshness.iter().filter(|f| f.starts_with("WARNING")) { eprintln!("{f}"); }+                let mut lines: Vec<String> = parts.iter().map(|p| format!("  {:<5} {:<5} target {:>2}  existing {:>2}  planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!("  ({n})")).unwrap_or_default())).collect();+                lines.extend(freshness.iter().map(|f| format!("  {f}")));+                r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note})).collect::<Vec<_>>(), "pourFreshness": freshness}));                 r.save().unwrap();                 if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }                 ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into()]);@@ -3764,7 +3909,12 @@ fn main() {             }             let mut r = load_run(&cli);             let asked = profile.clone().unwrap_or_else(|| "3rdPartyFab".into());-            let slug = match asked.to_ascii_lowercase().as_str() { "3rdpartyfab" | "fab" | "3rd-party-fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };+            // `--profile jlcpcb-6L` names the layers too; --layers must agree with it+            let (asked_base, asked_layers) = checks::split_profile(&asked);+            if let (Some(a), Some(b)) = (asked_layers, *layers) { if a != b { err(&format!("--profile {asked} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }+            let cb = board.clone().unwrap_or_else(|| r.current_board());+            let layers: u32 = layers.or(asked_layers).or_else(|| checks::board_copper_layers(&cb)).unwrap_or(2);+            let slug = match asked_base.as_str() { "fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };             let shown = if slug == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { slug.clone() };             let file = format!("{slug}-{layers}L.kicad_dru");             let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, &format!("kicad/{file}")]).output();@@ -3776,13 +3926,20 @@ fn main() {             let rd = dir.join("rules"); let _ = std::fs::create_dir_all(&rd);             std::fs::write(rd.join(&file), &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));             let mut placed = vec![rd.join(&file).display().to_string()];-            let cb = board.clone().unwrap_or_else(|| r.current_board());+            let mut replaced_note = String::new();             if cb.ends_with(".kicad_pcb") {                 let dru = Path::new(&cb).with_extension("kicad_dru");-                if dru.is_file() && !*force && std::fs::read_to_string(&dru).map(|t| !t.contains(&format!("adom-aiflow rules: {slug} "))).unwrap_or(false) {-                    err(&format!("{} already carries other rules", dru.display()), &["Pass --force to replace them with this pack (the old file is kept as .kicad_dru.bak).".into()]);+                let cur = std::fs::read_to_string(&dru).ok();+                let cur_pack = cur.as_deref().and_then(checks::pack_of);+                let same_pack = cur_pack.as_ref().is_some_and(|(s, l)| *s == slug && l.is_none_or(|l| l == layers));+                if cur.is_some() && !*force && !same_pack {+                    let what = cur_pack.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "other rules".into());+                    err(&format!("{} already carries {what}", dru.display()), &[format!("Pass --force to replace them with {slug}-{layers}L (the old file is kept as .kicad_dru.bak).")]);+                }+                if cur.is_some() {+                    let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak"));+                    if !same_pack { replaced_note = format!(" (replaced {}; the old file is {})", cur_pack.map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into()), dru.with_extension("kicad_dru.bak").display()); }                 }-                if dru.is_file() { let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak")); }                 std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));                 placed.push(dru.display().to_string());             }@@ -3810,41 +3967,66 @@ fn main() {             let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();             r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "kicad": placed, "fusion": fusion_loaded}));             r.save().unwrap();-            ok(&format!("design rules: {shown}, {layers} layers{fusion_note}. KiCad rules at {}", placed.join(", ")), &[+            ok(&format!("design rules: {shown}, {layers} layers{fusion_note}. KiCad rules at {}{replaced_note}", placed.join(", ")), &[                 format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),                 "Every later step designs to these: placement checks, routing widths and clearances, the gate's DRC and finish all read them. Design with margin above the minimums.".into()]);         }-        Cmd::Rules { action, profile, board, force, .. } => {+        Cmd::Rules { action, profile, board, force, layers } => {             let run = Run::open(&dir);             if run.is_some() { thread(&cli); }             let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("rules needs --board <file.kicad_pcb> outside a run", &[]));-            let profile = profile.clone().or_else(|| run.as_ref().and_then(|r| checks::rules_profile(&spec_of(r)))).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));+            // the profile: --profile, else the spec's fab.rules, else the run's fabRules (`rules use`)+            let target = run.as_ref().and_then(|r| checks::rules_target(&spec_of(r), &r.data["fabRules"]));+            if profile.is_none() { if let Some(w) = target.as_ref().and_then(|t| t.2.clone()) { eprintln!("Warning: {w}"); } }+            let recorded_layers = if profile.is_none() { target.as_ref().and_then(|t| t.1) } else { None };+            let profile = profile.clone().or_else(|| target.as_ref().map(|t| t.0.clone())).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|jlcpcb-<n>L|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));             match action.as_str() {                 "install" => {-                    if profile != "jlcpcb" {-                        err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb.".into()]);+                    let (base, suffix) = checks::split_profile(&profile);+                    if base != "jlcpcb" {+                        err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb (2 layers); `rules use --profile <fab> --layers <n>` loads any pack on adom/pcb-design-rules.".into()]);+                    }+                    if let (Some(a), Some(b)) = (suffix, *layers) { if a != b { err(&format!("--profile {profile} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }+                    let board_layers = checks::board_copper_layers(&board_path);+                    let n = layers.or(suffix).or(board_layers).or(recorded_layers).unwrap_or(2);+                    if let Some(bl) = board_layers.filter(|bl| *bl != n) {+                        err(&format!("--layers {n} but {board_path} has {bl} copper layers"), &[format!("Install the pack for the board's stackup: `adom-aiflow rules use --profile jlcpcb --layers {bl}`{}.", if bl == 2 { " or `rules install --profile jlcpcb --layers 2`" } else { "" })]);                     }+                    let shipped = checks::shipped_pack(&base, n).unwrap_or_else(|| err(&format!("no shipped jlcpcb {n}-layer rules: adom-aiflow ships only jlcpcb-2L"), &[format!("Load the {n}-layer pack from adom/pcb-design-rules instead: `adom-aiflow rules use --profile jlcpcb --layers {n}` (`rules list` shows the packs).")]));                     let dru = Path::new(&board_path).with_extension("kicad_dru");-                    if dru.is_file() && !*force {-                        let cur = std::fs::read_to_string(&dru).unwrap_or_default();-                        if cur.contains(checks::JLCPCB_MARKER) { ok(&format!("{} already carries the jlcpcb-2L profile", dru.display()), &[]); return; }-                        err(&format!("{} already exists and is another rules file", dru.display()), &["Keep it (it may be the 3rd party fab's rules) or pass --force to replace it with the jlcpcb-2L profile.".into()]);+                    let cur = std::fs::read_to_string(&dru).ok();+                    let cur_pack = cur.as_deref().and_then(checks::pack_of);+                    let describe = |p: &Option<(String, Option<u32>)>| p.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into());+                    let mut replaced = String::new();+                    if let Some(c) = &cur {+                        if cur_pack == Some(("jlcpcb".into(), Some(n))) { let _ = c; ok(&format!("{} already carries the jlcpcb-{n}L profile", dru.display()), &[]); return; }+                        if !*force {+                            err(&format!("{} already exists and carries {}", dru.display(), describe(&cur_pack)), &[format!("Keep it (it may be the 3rd party fab's rules or a pack `rules use` loaded) or pass --force to replace it with the jlcpcb-{n}L profile (the old file is kept as .kicad_dru.bak).")]);+                        }+                        let bak = dru.with_extension("kicad_dru.bak");+                        std::fs::copy(&dru, &bak).unwrap_or_else(|e| err(&format!("{}: {e}", bak.display()), &[]));+                        replaced = format!("; replaced {} (kept as {})", describe(&cur_pack), bak.display());                     }-                    std::fs::write(&dru, checks::JLCPCB_2L).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));+                    std::fs::write(&dru, shipped).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));                     let mut hints = vec!["DRC loads it with the board: `gate` and `place check` need a native kicad-cli for project rules (ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote in a container).".into(), "Set \"fab\": {\"rules\": \"jlcpcb\"} in the spec so the gate refuses a board without it, and source parts on the jlcpcb profile (skill aiflow-sourcing).".into()];                     if run.is_some() && board.is_none() { hints.push("Installed beside the run's current board; later snapshots carry it. Put the same file beside the source board too so the project keeps it.".into()); }-                    ok(&format!("installed the jlcpcb-2L rules (JLCPCB published 2-layer capabilities, cited in the file) at {}", dru.display()), &hints);+                    if let Some(fr) = run.as_ref().map(|r| r.data["fabRules"].clone()).filter(|f| f.is_object()) {+                        let (fs, fl) = (fr["slug"].as_str().unwrap_or(""), fr["layers"].as_u64());+                        if fs != "jlcpcb" || fl.is_some_and(|l| l as u32 != n) { hints.push(format!("Note: run.json fabRules still records {fs} {}L from `rules use`; the board now carries jlcpcb-{n}L.", fl.unwrap_or(0))); }+                    }+                    ok(&format!("installed the jlcpcb-{n}L rules (JLCPCB published {n}-layer capabilities, cited in the file) at {}{replaced}", dru.display()), &hints);                 }                 _ => {-                    let result = checks::rules_check(&profile, &board_path);+                    let fallback_layers = layers.or(recorded_layers);+                    let result = checks::rules_check_layers(&profile, &board_path, fallback_layers);                     // A read-only check of this run's selected project rules is proof too.                     // Do not rewrite custom rules, or qualify a different board/profile.                     if let Some(mut r) = run {                         let same_board = std::fs::canonicalize(&board_path).ok()                             .zip(std::fs::canonicalize(r.current_board()).ok())                             .is_some_and(|(a, b)| a == b);-                        let same_profile = checks::rules_profile(&spec_of(&r)).as_deref()-                            == Some(profile.as_str());+                        let same_profile = checks::rules_target(&spec_of(&r), &r.data["fabRules"])+                            .is_some_and(|t| t.0 == profile);                         let dru = Path::new(&board_path).with_extension("kicad_dru");                         let selected_dru = Path::new(&r.current_board()).with_extension("kicad_dru");                         let same_rules = dru == selected_dru || std::fs::canonicalize(&dru).ok()@@ -3865,7 +4047,7 @@ fn main() {                     }                     match result {                         Ok(m) => ok(&m, &[]),-                        Err(e) => err(&e, &[checks::rules_hint(&profile)]),+                        Err(e) => err(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]),                     }                 }             }@@ -4496,3 +4678,32 @@ mod clip_stop_tests {         let _ = std::fs::remove_dir_all(&dir);     } }++#[cfg(test)]+mod media_tools_tests {+    use super::*;++    #[test]+    fn a_missing_ffprobe_is_an_error_naming_it_not_a_blank_clip() {+        let e = media_tools_missing(&["aiflow-no-such-ffprobe"]).unwrap_err();+        assert!(e.starts_with("aiflow-no-such-ffprobe is not installed"), "{e}");+        let e = clip_verdict("/nonexistent/clip.mp4", &["aiflow-no-such-ffprobe", "ffmpeg"]).unwrap_err();+        assert!(e.contains("aiflow-no-such-ffprobe") && !e.contains("no frames"), "{e}");+    }++    #[test]+    fn a_clip_is_left_unjudged_when_the_tools_are_missing() {+        let dir = std::env::temp_dir().join(format!("aiflow-media-tools-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let file = dir.join("placement-1.mp4").display().to_string();+        std::fs::write(&file, b"not really a video").unwrap();+        let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": file, "step": "placement", "visit": 1}]}) };+        clip_artifacts_with(&mut r, &json!("placement-1"), &file, 10, &["aiflow-no-such-ffprobe"]);+        assert!(r.data["captures"][0].get("suspect").is_none(), "a missing tool must not flag the footage");+        let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();+        assert!(events.contains(&"clip-check-unavailable".to_string()) && !events.contains(&"clip-suspect".to_string()), "{events:?}");+        assert!(blank_check_verdict("no frames") && !blank_check_verdict("rejected by the AI: covered"));+        let _ = std::fs::remove_dir_all(dir);+    }+}
crates/adom-aiflow/src/sequence.rs+34
@@ -167,6 +167,23 @@ pub fn overdue_warning(data: &Value, flow: &Value, seq: &[String], now_secs: u64     Some(format!("still in {current} after {min} min (expected about {expected} min); declare the step you are actually on ({examples}, ...), even when your own scripts do that step's work. The timeline, the clips and the video depend on it.")) } +/// An `exec` still running for this run (issue #48): an `external-start` in the ledger with an+/// `execId` and no `external-end` for it, whose adom-aiflow process (`pid`) is still alive. A long+/// solve run through exec is the step's own work, so the over-budget warning waits for it. Starts+/// without an id (older binaries) never count: a crashed exec must not silence the warning for good.+pub fn open_exec(events: &[Value], alive: &dyn Fn(u64) -> bool) -> Option<Value> {+    let ended: std::collections::HashSet<&str> = events.iter().filter(|e| e["event"] == "external-end").filter_map(|e| e["execId"].as_str()).collect();+    events.iter().rev().filter(|e| e["event"] == "external-start")+        .find(|e| e["execId"].as_str().map(|id| !ended.contains(id)).unwrap_or(false) && e["pid"].as_u64().map(alive).unwrap_or(false))+        .cloned()+}++/// Whether a process is still running: /proc where there is one, else assumed (the exec's own end event closes it).+pub fn pid_alive(pid: u64) -> bool {+    let proc_root = Path::new("/proc");+    if proc_root.join("self").exists() { proc_root.join(pid.to_string()).exists() } else { true }+}+ /// The timeline the dashboard draws: every required step with its status, and every return as a loop. pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes: &serde_json::Map<String, Value>) -> Value {     let st = states(dir, data, flow, seq);@@ -277,6 +294,23 @@ mod tests {         assert!(blockers(&st, "b").is_empty());     } +    #[test]+    fn a_running_exec_holds_the_overdue_warning() {+        let start = |id: &str, pid: u64| json!({"event": "external-start", "execId": id, "pid": pid, "program": "adom-fields"});+        let alive = |pid: u64| pid == 7;+        // running and alive: open+        assert_eq!(open_exec(&[start("a", 7)], &alive).unwrap()["execId"], "a");+        // ended: closed+        assert!(open_exec(&[start("a", 7), json!({"event": "external-end", "execId": "a"})], &alive).is_none());+        // the process died without an end event (killed): not open, the warning comes back+        assert!(open_exec(&[start("b", 9)], &alive).is_none());+        // an older ledger without ids never silences the warning+        assert!(open_exec(&[json!({"event": "external-start", "program": "x"})], &alive).is_none());+        // two at once: the one still running counts+        assert_eq!(open_exec(&[start("a", 7), start("c", 7), json!({"event": "external-end", "execId": "c"})], &alive).unwrap()["execId"], "a");+        assert!(pid_alive(std::process::id() as u64));+    }+     #[test]     fn next_and_overdue() {         let flow = json!({"minutes": {"a": 10}, "short": {"b": "the b step"}});
crates/adom-aiflow/src/silkproof.rsadded+78
@@ -0,0 +1,78 @@+//! The KiCad lane's `silkscreen` step proof (issue #48): `silkscreen-audit --record` writes it only when+//! the whole-board audit passed AND its inventory covers the board it claims to audit: every footprint+//! reference named (id = the reference, or the reference followed by `:`, `-`, `.` or `/`), at least one+//! via/hole item per board via, and the visible labels at finish's per-footprint minimum. Fusion writes+//! the same proof from `fusion silkscreen`.++use aiflow_board::Board;+use serde_json::{json, Value};+use std::collections::BTreeSet;++fn covers(id: &str, reference: &str) -> bool {+    id == reference || id.strip_prefix(reference).and_then(|r| r.chars().next()).map(|c| matches!(c, ':' | '-' | '.' | '/')).unwrap_or(false)+}++/// The proof's evidence, or why it cannot be recorded.+pub fn proof(audit: &Value, input: &Value, board: &Board) -> Result<Value, Vec<String>> {+    let mut why = Vec::new();+    if audit["passed"] != true { why.push("the audit did not pass".to_string()); }+    let inventory = input["inventory"].as_array().cloned().unwrap_or_default();+    let ids: BTreeSet<String> = inventory.iter().filter_map(|i| i["id"].as_str().map(str::to_string)).collect();+    let refs: Vec<&String> = board.footprints.keys().collect();+    let uncovered: Vec<String> = refs.iter().filter(|r| !ids.iter().any(|id| covers(id, r))).map(|r| r.to_string()).collect();+    if !uncovered.is_empty() {+        why.push(format!("the audit inventory does not name {} of the board's {} footprints: {}{}", uncovered.len(), refs.len(), uncovered.iter().take(12).cloned().collect::<Vec<_>>().join(", "), if uncovered.len() > 12 { ", ..." } else { "" }));+    }+    let vias = board.root.find_all("via").count();+    let via_items = inventory.iter().filter(|i| matches!(i["kind"].as_str(), Some("via" | "hole"))).count();+    if via_items < vias { why.push(format!("the audit inventory has {via_items} via/hole item(s), the board {vias} vias")); }+    let (labels, fps) = crate::provenance::silk_labels(board);+    if fps > 0 && (labels as f64) < crate::provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {+        why.push(format!("{labels} visible silkscreen label(s) for {fps} footprints (at least {:.1} per footprint)", crate::provenance::MIN_SILK_PER_FOOTPRINT));+    }+    if !why.is_empty() { return Err(why); }+    Ok(json!({+        "pass": true, "footprints": refs.len(), "footprintsCovered": refs.len(), "vias": vias, "viaItems": via_items,+        "labels": labels, "inventoryItems": audit["inventoryItems"], "obstacles": audit["obstacles"], "marks": audit["marks"],+        "collisions": 0, "nativeVerified": false,+        "note": "geometry audit of the supplied whole-board inventory; native DRC, label association and both native 2D/3D views remain required",+    }))+}++#[cfg(test)]+mod tests {+    use super::*;+    const BOARD: &str = r#"(kicad_pcb (version 20240108)+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts") (width 0.1))+  (footprint "R" (layer "F.Cu") (at 2 2) (property "Reference" "R1" (at 0 -1) (layer "F.SilkS")) (property "Value" "10k" (at 0 1) (layer "F.SilkS"))+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")))+  (footprint "C" (layer "F.Cu") (at 6 2) (property "Reference" "C1" (at 0 -1) (layer "F.SilkS")) (property "Value" "1u" (at 0 1) (layer "F.SilkS"))+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")))+  (via (at 5 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu"))+)"#;++    #[test]+    fn records_only_a_passing_audit_that_covers_the_board() {+        let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+        let input = json!({"inventory": [{"id": "R1", "kind": "body", "layers": ["F.SilkS"]}, {"id": "C1:body", "kind": "body", "layers": ["F.SilkS"]}, {"id": "v1", "kind": "via", "layers": ["F.SilkS", "B.SilkS"]}]});+        let passed = json!({"passed": true, "inventoryItems": 3, "obstacles": 4, "marks": 4});+        let p = proof(&passed, &input, &b).unwrap();+        assert_eq!(p["footprints"], 2);+        assert_eq!(p["labels"], 4);+        assert!(proof(&json!({"passed": false}), &input, &b).unwrap_err()[0].contains("did not pass"));+        // R10 does not cover R1, and a missing via is missing evidence+        let thin = json!({"inventory": [{"id": "R10", "kind": "body"}, {"id": "C1", "kind": "body"}]});+        let why = proof(&passed, &thin, &b).unwrap_err().join("; ");+        assert!(why.contains("does not name 1") && why.contains(": R1") && why.contains("0 via/hole"), "{why}");+    }++    #[test]+    fn a_bare_board_is_not_a_silkscreen() {+        let bare = BOARD.replace("(layer \"F.SilkS\")", "(layer \"F.Fab\")");+        let b = Board::from_text("t.kicad_pcb", bare).unwrap();+        let input = json!({"inventory": [{"id": "R1", "kind": "body"}, {"id": "C1", "kind": "body"}, {"id": "v1", "kind": "via"}]});+        let why = proof(&json!({"passed": true}), &input, &b).unwrap_err().join("; ");+        assert!(why.contains("0 visible silkscreen label"), "{why}");+    }+}
crates/adom-aiflow/src/testplan.rs+51−12
@@ -88,16 +88,26 @@ pub fn add_test_points(netlist: &mut Value, gaps: &[String]) -> Vec<String> { }  /// Every footprint's body box (its fab outline, else its courtyard shrunk a little), placed.+/// Both sides together; `sided_bodies` keeps which side each body is on. pub fn bodies(b: &Board) -> Vec<(String, (f64, f64, f64, f64))> {+    sided_bodies(b).into_iter().map(|(r, _, bb)| (r, bb)).collect()+}++/// Every footprint's body box with its side: `true` for a front (F.Fab) body, `false` for a back+/// (B.Fab) one. A part with fab lines on both sides yields one box per side. A body only stands in+/// a probe's way on its own side (issue #46): a back-side part does not cover a front pad.+pub fn sided_bodies(b: &Board) -> Vec<(String, bool, (f64, f64, f64, f64))> {     let mut out = Vec::new();     for (r, fp) in &b.footprints {+        // a test point's own fab ring is not a body+        if r.starts_with("TP") { continue; }         let block = &b.text[fp.start..fp.end];         let Ok(node) = aiflow_board::sx::parse(block) else { continue };-        let mut pts = Vec::new();+        let (mut front, mut back) = (Vec::new(), Vec::new());         for n in node.nodes() {             if !matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc") { continue; }             let layer = n.find("layer").and_then(|l| l.value()).unwrap_or("");-            if layer != "F.Fab" && layer != "B.Fab" { continue; }+            let pts = match layer { "F.Fab" => &mut front, "B.Fab" => &mut back, _ => continue };             for tag in ["start", "end", "mid"] { if let Some(p) = n.find(tag) { if let (Some(x), Some(y)) = (p.number(1), p.number(2)) { pts.push((x, y)); } } }             if let Some(p) = n.find("pts") { for xy in p.find_all("xy") { if let (Some(x), Some(y)) = (xy.number(1), xy.number(2)) { pts.push((x, y)); } } }             if n.tag == "fp_circle" {@@ -108,17 +118,17 @@ pub fn bodies(b: &Board) -> Vec<(String, (f64, f64, f64, f64))> {                 }             }         }-        if pts.len() < 2 {-            // no fab outline (a board converted from Fusion): the pads' box is the body's footprint-            if r.starts_with("TP") { continue; }-            if let Some(bb) = b.pad_box(r) { out.push((r.clone(), bb)); }+        if front.len() < 2 && back.len() < 2 {+            // no fab outline (a board converted from Fusion): the pads' box is the body's footprint,+            // on the side the footprint is placed+            if let Some(bb) = b.pad_box(r) { out.push((r.clone(), !fp.layer.starts_with("B."), bb)); }             continue;         }-        let placed: Vec<(f64, f64)> = pts.iter().map(|&(dx, dy)| { let (a, c) = geom::rot_kicad(dx, dy, fp.rot); (fp.x + a, fp.y + c) }).collect();-        let bb = geom::bbox(&placed);-        // a test point's own fab ring is not a body-        if r.starts_with("TP") { continue; }-        out.push((r.clone(), bb));+        for (is_front, pts) in [(true, &front), (false, &back)] {+            if pts.len() < 2 { continue; }+            let placed: Vec<(f64, f64)> = pts.iter().map(|&(dx, dy)| { let (a, c) = geom::rot_kicad(dx, dy, fp.rot); (fp.x + a, fp.y + c) }).collect();+            out.push((r.clone(), is_front, geom::bbox(&placed)));+        }     }     out }@@ -127,7 +137,8 @@ fn in_box(p: (f64, f64), b: &(f64, f64, f64, f64), grow: f64) -> bool { p.0 > b.  /// Board mode: for each measured net, the best landing (x, y, on which pad) or why there is none. pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec<String>) {-    let bodies = bodies(b);+    // probes land from the front: only front-side bodies can cover a front pad (issue #46)+    let bodies: Vec<(String, (f64, f64, f64, f64))> = sided_bodies(b).into_iter().filter(|(_, front, _)| *front).map(|(r, _, bb)| (r, bb)).collect();     let grounds: Vec<(f64, f64, String)> = b.footprints.iter().filter(|(_, fp)| fp.lib.to_ascii_lowercase().contains("machinecontact"))         .flat_map(|(r, _)| b.pads_of(r).into_iter().filter(|p| p.net.eq_ignore_ascii_case("GND")).map(move |p| (p.x, p.y, r.clone()))).collect();     let (mut rows, mut gaps, mut warn) = (Vec::new(), Vec::new(), Vec::new());@@ -187,6 +198,34 @@ mod tests {         let (_, gaps2, _) = check_netlist(&tests, &nl);         assert!(gaps2.is_empty());     }+    /// A front test-point pad on VCC at (5, 5) and a second part whose body sits over it on `body_fab`.+    fn pad_and_body(body_side: &str, body_fab: &str) -> Board {+        let text = format!(r#"(kicad_pcb+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (net 1 "VCC")+  (gr_rect (start 0 0) (end 12 12) (layer "Edge.Cuts"))+  (footprint "test:Pad" (layer "F.Cu") (at 5 5) (property "Reference" "TP1")+    (pad "1" smd rect (at 0 0) (size 2 2) (layers "F.Cu") (net 1 "VCC")))+  (footprint "test:Body" (layer "{body_side}") (at 5 5) (property "Reference" "U2")+    (fp_rect (start -3 -3) (end 3 3) (layer "{body_fab}"))+    (pad "1" smd rect (at 4 4) (size 0.5 0.5) (layers "{body_side}"))))"#);+        Board::from_text("test.kicad_pcb", text).unwrap()+    }+    #[test]+    fn back_body_does_not_cover_a_front_pad() {+        // issue #46: a B.Fab body under an exposed F.Cu pad leaves it probe-able+        let tests = vec![json!({"id": "vcc", "net": "VCC", "kind": "dc"})];+        let b = pad_and_body("B.Cu", "B.Fab");+        assert_eq!(sided_bodies(&b), vec![("U2".to_string(), false, (2.0, 2.0, 8.0, 8.0))]);+        let (rows, gaps, _) = check_board(&tests, &b);+        assert!(gaps.is_empty(), "{rows:?}");+        assert_eq!(rows[0]["status"], "test point");+        // the same body on the front covers the pad: a gap+        let b = pad_and_body("F.Cu", "F.Fab");+        let (rows, gaps, _) = check_board(&tests, &b);+        assert_eq!(gaps, vec!["VCC".to_string()]);+        assert!(rows[0]["why"].as_str().unwrap().contains("under U2"), "{rows:?}");+    }     #[test]     fn netlist_as_object_like_the_fusion_steps() {         // the Fusion steps' shape: nets {name: [pins]}; read the same, written back the same
crates/adom-aiflow/src/thermalvias.rs+96
@@ -141,6 +141,67 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a     (parts, json!({"engine": "adom-aiflow thermal-vias plan", "nets": entries})) } +/// A short, order-independent hash of a closed outline: points rounded to 0.01 mm, wound one way,+/// starting at the smallest point, so the same polygon read back from KiCad hashes the same.+pub fn outline_hash(poly: &[(f64, f64)]) -> String {+    use sha2::{Digest, Sha256};+    let mut pts: Vec<(i64, i64)> = poly.iter().map(|&(x, y)| ((x * 100.0).round() as i64, (y * 100.0).round() as i64)).collect();+    if pts.len() > 1 && pts.first() == pts.last() { pts.pop(); }+    pts.dedup();+    let area: i64 = (0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); a.0 * b.1 - b.0 * a.1 }).sum();+    if area < 0 { pts.reverse(); }+    if let Some(k) = (0..pts.len()).min_by_key(|&i| pts[i]) { pts.rotate_left(k); }+    let text: String = pts.iter().map(|(x, y)| format!("{x},{y};")).collect();+    format!("{:x}", Sha256::digest(text.as_bytes()))[..12].to_string()+}++/// Issue #40: the planner trusts spec.pours polygons, which go stale when the board's zones move+/// (a part rotated, a zone redrawn) and then every via reads as outside the copper. Compare each+/// spec pour with an explicit polygon against the board's zone of the same net and layer (the same+/// name first): a WARNING when they differ, telling a pour this flow landed (`landed`: the run's+/// land-pours record passed) that is now stale from one that was never landed. Read-only: nothing+/// is changed or blocked here.+pub fn pour_freshness(spec: &Value, b: &Board, landed: bool) -> Vec<String> {+    let mut zones: Vec<(String, String, Vec<String>, Vec<(f64, f64)>)> = Vec::new();+    for z in b.root.find_all("zone") {+        if z.find("keepout").is_some() { continue; }+        let mut layers: Vec<String> = Vec::new();+        for key in ["layer", "layers"] {+            if let Some(n) = z.find(key) { layers.extend((1..).map_while(|i| n.atom(i)).map(str::to_string)); }+        }+        let outline = z.find("polygon").and_then(|p| p.find("pts")).map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default();+        let name = z.find("name").and_then(|n| n.value()).unwrap_or("").to_string();+        zones.push((b.item_net(z), name, layers, outline));+    }+    let on_layer = |ls: &[String], l: &str| ls.iter().any(|x| x == l || x == "*.Cu" || (x == "F&B.Cu" && (l == "F.Cu" || l == "B.Cu")));+    let mut out = Vec::new();+    for p in spec["pours"].as_array().into_iter().flatten() {+        let Some(net) = p["net"].as_str() else { continue };+        let Some(poly) = p["polygon"].as_array().and_then(|a| a.iter().map(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect::<Option<Vec<_>>>()) else { continue };+        let name = p["name"].as_str().map(str::to_string).unwrap_or_else(|| format!("{net} pour"));+        let planned = outline_hash(&poly);+        for layer in p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()) {+            let same: Vec<_> = zones.iter().filter(|z| z.0 == net && on_layer(&z.2, layer)).collect();+            let found = same.iter().find(|z| z.1 == name).or(same.first());+            match found {+                None if landed => out.push(format!("WARNING stale pour: {name} ({net} {layer}) was landed by this flow but the board has no {net} zone on {layer} now (planned outline {planned}, board none); `pour` again before trusting the via counts")),+                None => out.push(format!("note: pour {name} ({net} {layer}) is not on the board yet (never landed); vias are counted against the planned outline {planned}")),+                Some(z) => {+                    let actual = outline_hash(&z.3);+                    if actual == planned { continue; }+                    let zname = if z.1.is_empty() { "unnamed".to_string() } else { z.1.clone() };+                    if landed {+                        out.push(format!("WARNING stale pour: {name} ({net} {layer}) was landed by this flow but the board's zone ({zname}) has moved: planned outline {planned}, board outline {actual}; existing/planned via counts use the old outline. Update spec.pours (or `pour` again) and plan again"));+                    } else {+                        out.push(format!("WARNING pour differs from the board: {name} ({net} {layer}) was never landed by this flow, and the board's own zone ({zname}) has another outline: planned outline {planned}, board outline {actual}; the via counts use the planned outline. Update spec.pours to the board's zone if that zone is the copper"));+                    }+                }+            }+        }+    }+    out+}+ /// After landing: (reference, have, target) for every hot part short of its target. pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, usize)> {     let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);@@ -219,6 +280,41 @@ mod tests {         assert_eq!(super::short(&spec, &saved, &[])[0].1, 0);     } +    fn zone_board(zone_poly: &str) -> aiflow_board::Board {+        aiflow_board::Board::from_text("test.kicad_pcb", format!(r#"(kicad_pcb+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (net 1 "GND")+  (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+  (footprint "t" (layer "F.Cu") (at 10 10) (property "Reference" "D1")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND")))+  (zone (net 1) (net_name "GND") (layers "F.Cu" "B.Cu") (name "GND pour")+    (polygon (pts {zone_poly}))))"#)).unwrap()+    }+    #[test]+    fn stale_pours_are_warned_and_never_landed_ones_told_apart() {+        // issue #40: the spec's pour where the board's zone used to be+        let spec = serde_json::json!({"pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[5,5],[15,5],[15,15],[5,15]]}]});+        // the board's zone is the same polygon, written from another corner and the other way round+        let same = zone_board("(xy 5 15) (xy 15 15) (xy 15 5) (xy 5 5)");+        assert!(super::pour_freshness(&spec, &same, true).is_empty());+        // the zone moved: landed by this flow means stale; otherwise it was never landed+        let moved = zone_board("(xy 20 20) (xy 28 20) (xy 28 28) (xy 20 28)");+        let w = super::pour_freshness(&spec, &moved, true);+        assert_eq!(w.len(), 2, "{w:?}");+        assert!(w[0].starts_with("WARNING stale pour: GND pour (GND F.Cu)"), "{w:?}");+        let (planned, actual) = (super::outline_hash(&[(5.,5.),(15.,5.),(15.,15.),(5.,15.)]), super::outline_hash(&[(20.,20.),(28.,20.),(28.,28.),(20.,28.)]));+        assert!(w[0].contains(&planned) && w[0].contains(&actual) && planned != actual, "{w:?}");+        let n = super::pour_freshness(&spec, &moved, false);+        assert!(n[1].starts_with("WARNING pour differs from the board: GND pour (GND B.Cu) was never landed"), "{n:?}");+        // no zone at all: a note when never landed, a warning when the run says it landed+        let mut none = spec.clone();+        none["pours"][0]["net"] = serde_json::json!("VIN");+        assert!(super::pour_freshness(&none, &same, false)[0].starts_with("note: pour VIN pour (VIN F.Cu) is not on the board yet"));+        assert!(super::pour_freshness(&none, &same, true)[0].starts_with("WARNING stale pour: VIN pour"));+        // the plan itself is unchanged by the check+        let (parts, _) = super::plan(&serde_json::json!({"hot": {"D1": {"watts": 0.5, "tabNet": "GND"}}, "pours": spec["pours"].clone()}), &moved, &[]);+        assert_eq!(parts.len(), 1);+    }     #[test]     fn targets_scale_with_power() {         assert_eq!(super::target(0.25, 10.0), 6);
crates/aiflow-copper/src/lib.rs+134−3
@@ -74,9 +74,66 @@ fn point(spec: &Value, board: &Board) -> Result<(f64, f64, Option<usize>, Option     Err(format!("bad waypoint {spec}")) } +/// Make a plan say only what it lands, once, so the offline gate and `land route` agree (issue #33).+/// A waypoint at the same point and on the same layer as the one before it is dropped (it would+/// be a zero-length segment the bridge refuses mid-landing); the same point on another layer is a+/// via and stays. A path left with fewer than two waypoints has no geometry and is dropped, and an+/// entry left with no path is dropped. Returns one note per change, naming the net and the path.+/// Waypoints that do not resolve (an unknown pad) are kept as they are for the gate to report.+pub fn normalise_plan(board: &Board, plan: &mut Value) -> Vec<String> {+    let mut notes = Vec::new();+    let entries = match plan.get_mut("nets").and_then(|n| n.as_array_mut()) {+        Some(a) => a,+        None => match plan.as_array_mut() { Some(a) => a, None => return notes },+    };+    let mut keep_entries = Vec::with_capacity(entries.len());+    for (ei, mut e) in std::mem::take(entries).into_iter().enumerate() {+        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("?").to_string();+        let entry_layer = e.get("layer").and_then(|l| l.as_str()).map(str::to_string);+        let key = if e.get("paths").map(|p| p.is_array()).unwrap_or(false) { "paths" } else if e.get("points").map(|p| p.is_array()).unwrap_or(false) { "points" } else { keep_entries.push(e); continue };+        let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };+        let mut kept_paths = Vec::with_capacity(paths.len());+        for (pi, path) in paths.into_iter().enumerate() {+            let Some(pts) = path.as_array() else { kept_paths.push(path); continue };+            let where_ = if key == "paths" { format!("{net} (entry {}, path {})", ei + 1, pi + 1) } else { format!("{net} (entry {})", ei + 1) };+            let mut layer: Option<String> = entry_layer.clone();+            let mut prev: Option<(f64, f64)> = None;+            let mut out: Vec<Value> = Vec::with_capacity(pts.len());+            let mut unresolved = false;+            for wp in pts {+                let Ok((x, y, _, explicit)) = point(wp, board) else { unresolved = true; out.push(wp.clone()); prev = None; continue };+                let cur = layer.clone().or_else(|| explicit.clone()).unwrap_or_else(|| "F.Cu".to_string());+                let nxt = explicit.unwrap_or_else(|| cur.clone());+                if let Some((px, py)) = prev {+                    if (px - x).abs() < 1e-6 && (py - y).abs() < 1e-6 && nxt == cur {+                        notes.push(format!("{where_}: dropped a repeated waypoint at ({}, {}) on {cur}", num(x), num(y)));+                        continue;+                    }+                }+                out.push(wp.clone());+                layer = Some(nxt);+                prev = Some((x, y));+            }+            if out.len() < 2 && !unresolved {+                notes.push(format!("{where_}: dropped the path, no length left ({} waypoint{})", out.len(), if out.len() == 1 { "" } else { "s" }));+                continue;+            }+            kept_paths.push(Value::Array(out));+        }+        if kept_paths.is_empty() {+            notes.push(format!("{net} (entry {}): dropped the entry, no geometry left", ei + 1));+            continue;+        }+        if key == "paths" { e["paths"] = Value::Array(kept_paths); } else { e["points"] = kept_paths.into_iter().next().unwrap(); }+        keep_entries.push(e);+    }+    *entries = keep_entries;+    notes+}+ /// One plan entry to its segments and vias. pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<ViaSpec>), String> {-    let net = entry.get("net").and_then(|n| n.as_str()).ok_or("entry needs net")?;+    let net = entry.get("net").and_then(|n| n.as_str()).ok_or_else(|| format!("entry needs net: {PLAN_SHAPE}"))?;     if !board.nets.contains_key(net) {         return Err(format!("unknown net {net:?}"));     }@@ -85,7 +142,7 @@ pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<Via     let drill = entry.get("viaDrill").and_then(|w| w.as_f64()).unwrap_or(0.3);     let paths: Vec<Value> = match entry.get("paths").and_then(|p| p.as_array()) {         Some(p) => p.clone(),-        None => vec![entry.get("points").cloned().ok_or("entry needs paths or points")?],+        None => vec![entry.get("points").cloned().ok_or_else(|| format!("entry needs paths or points: {PLAN_SHAPE}"))?],     };     let mut segments = Vec::new();     let mut vias = Vec::new();@@ -403,8 +460,26 @@ pub fn fill_zones(board: &Board, text: &str, vias_by_net: &HashMap<String, Vec<V }  /// The board text with the plan's copper (and raster fills unless `fill` is false), and stats.+/// What a route plan must look like, for errors that refuse one. docs/plan-schema.md has the rest.+pub const PLAN_SHAPE: &str = "a route plan is {\"nets\": [entry, ...]} or a bare [entry, ...]; each entry is {\"net\": \"VIN\", \"width\": 0.25, \"viaSize\": 0.6, \"viaDrill\": 0.3, \"paths\": [[point, point, ...], ...]} (or one path as \"points\"), and a point is a pad key \"U1.3\", [x, y] in mm, {\"x\": x, \"y\": y, \"layer\": \"B.Cu\"} or {\"pad\": \"U1.3\", \"layer\": \"B.Cu\"}; a layer that differs from the current one drops a through via there (docs/plan-schema.md)";++/// The entries of a route plan: `{"nets": [...]}` or a bare array.+pub fn plan_entries(plan: &Value) -> Result<Vec<Value>, String> {+    plan.get("nets").and_then(|n| n.as_array()).cloned().or_else(|| plan.as_array().cloned())+        .ok_or_else(|| format!("plan needs nets: {PLAN_SHAPE}"))+}++/// A route plan in its object form: a bare array of entries becomes `{"nets": [...]}`, so every+/// reader of `plan["nets"]` (gate, land route, the via prechecks) sees the same entries.+pub fn normalize_plan(plan: Value) -> Value {+    match plan {+        Value::Array(a) => json!({"nets": a}),+        v => v,+    }+}+ pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value), String> {-    let entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().or_else(|| plan.as_array().cloned()).ok_or("plan needs nets")?;+    let entries: Vec<Value> = plan_entries(plan)?;     let mut blocks = Vec::new();     let mut vias_by_net: HashMap<String, Vec<ViaSpec>> = HashMap::new();     let (mut n_seg, mut n_via) = (0usize, 0usize);@@ -584,3 +659,59 @@ mod gate_tests {         assert_eq!(summarize(&json!({"violations":[over, overlap, library]}),None)["newErrors"],2);     } }++#[cfg(test)]+mod normalise_tests {+    use super::*;+    fn board() -> Board {+        Board::from_text("test.kicad_pcb", r#"(kicad_pcb+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (net 1 "VCC")+  (gr_rect (start 0 0) (end 200 200) (layer "Edge.Cuts"))+  (footprint "t" (layer "F.Cu") (at 80 100) (property "Reference" "U1")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "VCC"))))"#.into()).unwrap()+    }+    #[test]+    fn zero_length_path_is_dropped_before_gate_and_landing() {+        // issue #33: this passed the gate and stopped `land route` with zero_length+        let b = board();+        let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [[[88.2, 106.8], [88.2, 106.8]]]},+            {"net": "VCC", "width": 0.25, "paths": [[[80.0, 100.0], [80.0, 100.0], [85.0, 100.0]], [[85.0, 100.0], [85.0, 100.0]]]}]});+        let notes = normalise_plan(&b, &mut plan);+        assert_eq!(plan["nets"].as_array().unwrap().len(), 1, "{plan}");+        assert_eq!(plan["nets"][0]["paths"], json!([[[80.0, 100.0], [85.0, 100.0]]]));+        assert!(notes.iter().any(|n| n.contains("VCC (entry 1): dropped the entry")), "{notes:?}");+        assert!(notes.iter().any(|n| n.contains("entry 2, path 1): dropped a repeated waypoint at (80, 100) on F.Cu")), "{notes:?}");+        assert!(notes.iter().any(|n| n.contains("entry 2, path 2): dropped the path")), "{notes:?}");+        // what is left applies, and normalising again changes nothing+        assert!(apply(&b, &plan, false).is_ok());+        assert!(normalise_plan(&b, &mut plan).is_empty());+    }+    #[test]+    fn a_path_starting_on_back_copper_lands_on_back_copper() {+        // issue #37: the router exports a start on existing B.Cu copper as {x, y, layer: "B.Cu"};+        // the gate copy puts the first segment on B.Cu with no via at the start+        let b = board();+        let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [[{"x": 84.0, "y": 104.0, "layer": "B.Cu"}, [86.0, 104.0], [86.0, 106.0]]]}]});+        assert!(normalise_plan(&b, &mut plan).is_empty());+        let (segs, vias) = plan_to_copper(&b, &plan["nets"][0]).unwrap();+        assert_eq!(segs.len(), 2);+        assert!(segs.iter().all(|s| s.layer == "B.Cu"), "{segs:?}");+        assert!(vias.is_empty());+    }+    #[test]+    fn coincident_points_on_different_layers_stay_a_via() {+        let b = board();+        let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [["U1.1", [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 104.0]]]}]});+        let notes = normalise_plan(&b, &mut plan);+        assert_eq!(notes.len(), 1, "{notes:?}");+        assert!(notes[0].contains("(84, 100) on B.Cu"), "{notes:?}");+        assert_eq!(plan["nets"][0]["paths"][0].as_array().unwrap().len(), 4);+        let (segs, vias) = plan_to_copper(&b, &plan["nets"][0]).unwrap();+        assert_eq!((segs.len(), vias.len()), (2, 1));+        // a via-only path (same point, two layers) keeps its geometry+        let mut via_only = json!({"nets": [{"net": "VCC", "points": [{"x": 90.0, "y": 90.0, "layer": "F.Cu"}, {"x": 90.0, "y": 90.0, "layer": "B.Cu"}]}]});+        assert!(normalise_plan(&b, &mut via_only).is_empty());+        assert_eq!(via_only["nets"][0]["points"].as_array().unwrap().len(), 2);+    }+}
crates/aiflow-copper/tests/plan_shape.rsadded+52
@@ -0,0 +1,52 @@+//! The route plan JSON shape `gate --plan` and `land route --plan` accept (docs/plan-schema.md).+use aiflow_board::Board;+use aiflow_copper::{apply, normalize_plan, plan_entries, plan_to_copper};+use serde_json::json;++fn board() -> Board {+    Board::from_text("plan.kicad_pcb", r#"(kicad_pcb+      (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+      (net 1 "SIG")+      (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts"))+      (footprint "smd" (layer "F.Cu") (at 5 5) (property "Reference" "U1")+        (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "SIG")))+      (footprint "tht" (layer "F.Cu") (at 15 5) (property "Reference" "J1")+        (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 0.8) (layers "*.Cu" "*.Mask") (net 1 "SIG"))))"#.into()).unwrap()+}++#[test]+fn every_point_form_and_layer_marker() {+    let b = board();+    // pad key, [x, y], {x, y, layer} (a via where the layer changes), {pad, layer}+    let e = json!({"net": "SIG", "width": 0.3, "viaSize": 0.7, "viaDrill": 0.35,+        "paths": [["U1.1", [10.0, 5.0], {"x": 10.0, "y": 8.0, "layer": "B.Cu"}, {"pad": "J1.1", "layer": "B.Cu"}]]});+    let (segs, vias) = plan_to_copper(&b, &e).unwrap();+    assert_eq!(segs.len(), 3);+    assert_eq!((segs[0].layer.as_str(), segs[1].layer.as_str(), segs[2].layer.as_str()), ("F.Cu", "F.Cu", "B.Cu"));+    assert!(segs.iter().all(|s| s.width == 0.3));+    assert_eq!(vias.len(), 1);+    assert_eq!((vias[0].x, vias[0].y, vias[0].size, vias[0].drill), (10.0, 8.0, 0.7, 0.35));+    // defaults: width 0.25, via 0.6 / 0.3; one path may be given as "points"+    let (segs, _) = plan_to_copper(&b, &json!({"net": "SIG", "points": ["U1.1", [8.0, 5.0]]})).unwrap();+    assert_eq!(segs[0].width, 0.25);+    // a via ending a path: copper on the arrival layer only, the other side meets a pour+    let (segs, vias) = plan_to_copper(&b, &json!({"net": "SIG", "paths": [["U1.1", [7.0, 5.0], {"x": 7.0, "y": 5.0, "layer": "B.Cu"}]]})).unwrap();+    assert_eq!((segs.len(), vias.len()), (1, 1));+    assert_eq!(segs[0].layer, "F.Cu");+    // an SMD pad reached on the wrong layer is refused+    assert!(plan_to_copper(&b, &json!({"net": "SIG", "paths": [[{"pad": "U1.1", "layer": "B.Cu"}, [8.0, 5.0]]]})).unwrap_err().contains("no copper on B.Cu"));+}++#[test]+fn object_and_bare_array_plans_are_the_same() {+    let b = board();+    let entry = json!({"net": "SIG", "paths": [["U1.1", "J1.1"]]});+    let (_, a) = apply(&b, &json!({"nets": [entry.clone()]}), false).unwrap();+    let (_, bare) = apply(&b, &json!([entry.clone()]), false).unwrap();+    assert_eq!(a, bare);+    assert_eq!(normalize_plan(json!([entry.clone()])), json!({"nets": [entry]}));+    assert_eq!(normalize_plan(json!({"nets": [], "note": "x"}))["note"], "x");+    let e = plan_entries(&json!({"traces": []})).unwrap_err();+    assert!(e.starts_with("plan needs nets") && e.contains("bare [entry") && e.contains("{\"pad\"") && e.contains("plan-schema.md"), "{e}");+    assert!(apply(&b, &json!({"nets": [{"paths": [["U1.1", "J1.1"]]}]}), false).unwrap_err().contains("entry needs net"));+}
crates/aiflow-router/tests/obstacles.rs+30
@@ -39,3 +39,33 @@ fn released_clear_escape_becomes_searchable_again() {     r.reserve_escapes(0,c);     assert_eq!(r.nets[0].clusters[c].terminals.iter().filter(|x| **x==t).count(),1); }++/// Issue #36: an unnumbered, no-net NPTH mounting hole (KiCad 7 "*.Cu" and KiCad 8+ "F&B.Cu"+/// layer spellings) stays a router obstacle: no via on or beside it, no track through it.+#[test]+fn unnumbered_npth_mounting_hole_blocks_vias_and_tracks() {+    for layers in ["\"*.Cu\" \"*.Mask\"", "\"F&B.Cu\" \"*.Mask\""] {+        let board = Board::from_text("npth.kicad_pcb", format!(r#"(kicad_pcb+          (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+          (net 1 "SIG")+          (gr_rect (start 0 0) (end 20 12) (layer "Edge.Cuts"))+          (footprint "test" (layer "F.Cu") (at 3 6) (property "Reference" "J1")+            (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "SIG")))+          (footprint "test" (layer "F.Cu") (at 17 6) (property "Reference" "J2")+            (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "SIG")))+          (footprint "MountingHole" (layer "F.Cu") (at 10 6) (property "Reference" "H1")+            (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers {layers}))))"#)).unwrap();+        let hole = board.pads.iter().find(|p| p.reference == "H1").expect("the unnumbered hole is kept");+        assert!(hole.net.is_empty() && hole.thru);+        let mut r = Router::new(board, Rules::default(), false);+        let win = (0, 0, r.grid.w as i64 - 1, r.grid.h as i64 - 1);+        let (orth, _, _, via_ok) = r.obstacle_maps(0, 0.25, win, false);+        // the hole's centre, and a point just outside its edge (a via there would overlap the drill)+        for x in [10.0, 10.0 + 1.6 + 0.2] {+            let (i, j) = r.grid.to_cell(x, 6.0);+            assert!(!via_ok.get(i as usize, j as usize), "{layers}: via allowed at x={x}");+        }+        let (i, j) = r.grid.to_cell(10.0, 6.0);+        assert!(orth[0].get(i as usize, j as usize) && orth[1].get(i as usize, j as usize), "{layers}: track allowed through the hole");+    }+}
docs/plan-schema.mdadded+63
@@ -0,0 +1,63 @@+# Route plan JSON++`adom-aiflow route` writes this shape, and `gate --plan <plan.json>` and `land route --plan <plan.json>` read it. Write it when you route a board yourself (High-speed boards in SKILL.md). The reader is `aiflow_copper::apply` (gate) and `aiflow_bridge::landing` (land route); both take the same grammar.++## Top level++Either an object with a `nets` array, or a bare array of the same entries:++```json+{"nets": [ENTRY, ENTRY, ...]}+```++```json+[ENTRY, ENTRY, ...]+```++Other top-level keys (`engine`, `note`, `planeUnder`, ...) are kept as notes and ignored by the copper. `{"nets": []}` is an empty plan: the board is gated and landed as it is.++## Entry++```json+{"net": "VIN", "width": 0.5, "viaSize": 0.6, "viaDrill": 0.3,+ "paths": [["U1.3", [12.0, 8.5], {"x": 12.0, "y": 11.0, "layer": "B.Cu"}, {"pad": "J1.1", "layer": "B.Cu"}]]}+```++| field | required | meaning |+|---|---|---|+| `net` | yes | the net name exactly as the board has it |+| `width` | no | track width in mm (default 0.25) |+| `viaSize` | no | via pad diameter in mm (default 0.6) |+| `viaDrill` | no | via drill in mm (default 0.3) |+| `paths` | yes, or `points` | a list of paths; each path is a list of 2 to 512 points drawn as one trace |+| `points` | instead of `paths` | a single path |+| `layer` | no | the layer the first point starts on (default: the first point's own layer, else F.Cu) |++One net may have several entries (the router writes one per track width). `land route` lands each path as its own trace.++## Points++| form | example | meaning |+|---|---|---|+| pad key | `"U1.3"` | the pad's centre, on the current layer |+| `[x, y]` | `[12.0, 8.5]` | a corner in board mm, on the current layer |+| `{x, y, layer}` | `{"x": 12, "y": 11, "layer": "B.Cu"}` | a corner; `layer` is optional |+| `{pad, layer}` | `{"pad": "J1.1", "layer": "B.Cu"}` | the pad's centre, reached on that layer |++Layers are copper layer names of the board (`F.Cu`, `B.Cu`, `In1.Cu`, ...). A pad must be on the entry's net and must have copper on the layer the path reaches it on (an SMD pad on F.Cu cannot be reached on B.Cu; a through-hole pad can).++## Vias++There is no via object. A point whose `layer` differs from the current layer puts a through via (F.Cu to B.Cu, `viaSize`/`viaDrill`) at that point, and the path continues on the new layer. The router writes the layer change as its own point at the same x, y as the corner before it.++A via with copper on one layer only: end the path at the via. The last point repeats the previous x, y with the other layer, so the trace arrives on one layer and the via joins a pour (or a plane) on the other, with no track there:++```json+{"net": "GND", "width": 0.4, "paths": [["C3.2", [20.0, 14.0], {"x": 20.0, "y": 14.0, "layer": "B.Cu"}]]}+```++A pad key after such a via (`... {"x": 20, "y": 14, "layer": "B.Cu"}, "J2.1"]`) reaches that pad on the via's layer.++## Errors++`plan needs nets` means the file is neither `{"nets": [...]}` nor a bare array. `entry needs net`, `unknown net`, `unknown pad`, `pad X is on A, not B`, `pad X has no copper on L` and `path needs 2..512 waypoints` name the entry to fix.
docs/silkscreen.md+2
@@ -88,6 +88,8 @@ Before placing or accepting silkscreen, enumerate every fitted model, via and dr  Reserve every via hole on both faces, including tented vias when visible labeling is the requirement, and use the larger of the hole keepout and actual exposed mask opening. Also reserve through holes, slots, exposed pads and existing silk. Check actual stroked text extents, not just its anchor. After each re-layout, audit ALL existing labels and leaders against this complete obstacle set. A collision-free position is not enough: keep each reference/value next to its own component or add a clear, noncrossing leader. Do not move a label beside a different component merely to clear an obstacle. Native DRC and fitted 3D visibility are separate acceptance checks; a DRC baseline does not establish readable assembled silkscreen. Refresh and inspect both native 2D and 3D views before presenting the result. +`adom-aiflow silkscreen-audit --input <audit.json> --out <result.json> --record` checks this coverage against the board and records the KiCad lane's silkscreen proof only when the audit passes, every footprint reference is in the inventory (id `R1`, or `R1:body`, `R1-value`), every via has a via or hole item and the board carries at least 0.5 visible labels per footprint. The proof keeps the board, audit and input hashes. It is a geometry audit; native DRC and both native views remain required.+ ## Optional live silkscreen dashboard  Offer `silkscreen-dashboard show` with the run and ai-thread flags. The shared observer renders real timestamped solver events, sampled candidate bounds and reasons, selected proposals and unresolved labels, in 2D and a fitted-model 3D preview. `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` emits these without AI/provider calls or deliberate solver pauses. Read `tools/silkscreen-dashboard/SKILL.md` for the manifest, transform limits, lifecycle and recording contract. Live and replay are visibly distinct; replay exports are not raw native CAD footage. Selected proposals remain unverified until native application, DRC and assembled visibility checks pass. Fusion/Altium adapters are not implemented merely because the manifest is tool-neutral.
flows/board.json+4−2
@@ -211,6 +211,7 @@     {       "name": "silkscreen",       "who": "ai",+      "binary": ["silkscreen-layout", "silkscreen-audit --record"],       "does": "Make both faces useful in real service: references and values, verified connector and machine-contact pinouts, polarity, board identity and bring-up labels; review native plots and assembled visibility. Offer the optional shared silkscreen dashboard: real solver events, candidate reasons, live/replay separation, fitted-model inspection and separately recorded detailed/5s replays. Audit every fitted body and every via/hole across the whole board, both faces, with coverage counts. Missing Fab geometry is not free space; native DRC does not establish fitted visibility. Keep unresolved labels explicit and do not apply an incomplete layout. Native geometry/edit/undo/refresh belongs to the EDA bridge.",       "workflow": [         "Read the 3rd party fab silkscreen skill for the selected process; reserve label space during placement and finalize after copper and analysis stabilize. Use both faces, a clear font-size hierarchy, and the approved process profile for small secondary text.",@@ -224,7 +225,8 @@         "Require positional contact labels on BOTH faces: a pinout table is supplementary, never a substitute for text beside each actual machine contact, machine pin or edge connection. Separate primary reference (MC10) and smaller secondary function (DSHOT) as independently sized paired text. Prefer consistent reading directions. Where association remains ambiguous, add a short gentle curved silkscreen leader ending outside the intended pad mask opening; avoid crossings and obstacles. Verify one-to-one pad association, bottom mirroring, label/leader clearance and legibility in close-up native views and DRC. Film real additions and rework; retain mapping and unresolved constraints. See docs/silkscreen.md.",         "For the requested 3rd party fab profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.",         "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",-        "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md."+        "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md.",+        "KiCad lane: `adom-aiflow silkscreen-audit --input <audit.json> --out <result.json> --record` is the step's proof. It is recorded only when the whole-board audit passes and its inventory names every footprint by its reference and a via or hole item for every via, with at least 0.5 visible labels per footprint (Fusion: `fusion silkscreen`)."       ],       "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3–5 second final-video excerpt. Clearly label reconstructed replay.",       "hints": [@@ -274,7 +276,7 @@       ],       "record": "the AI's prompt asking for the files, the export running, then the chat listing every file with its path and saying they are good to go",       "workflow": [-        "`adom-aiflow export` (Fusion: `fusion export`) writes the manufacturing set, re-reads every file (layer count, drill hits, BOM refs against the board, CPL refs and rotations) and refuses a set that does not match the board."+        "`adom-aiflow export` (Fusion: `fusion export`) writes the manufacturing set, re-reads every file (layer count, drill hits, BOM refs against the board, CPL refs and rotations) and refuses a set that does not match the board. KiCad lane: kicad-cli through ADOM_AIFLOW_KICAD_CLI (adom-aiflow-kicad-cli-remote in a container) writes the Gerbers, drill and CPL into <run>/manufacturing; the BOM is built from the board's footprints. The proof records what was checked; bottom-side CPL rotations are counted, not compared."       ]     }   ],
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.93",+  "version": "0.1.94",   "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.93",+  "version": "0.1.94",   "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+26−7
@@ -12,7 +12,7 @@ Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kic  Every state-changing command takes `--ai-thread "<your thread name>"`. Every run lives in one directory (`--run DIR`, default `./aiflow-run`) with a `run.json` manifest: the clock, the plan, the decisions, every stage's start and end, the outcomes, the capture markers and the summary. The manifest is the comparison; nothing counts that is not in it. -Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it. `intake`, `start`, `prompt`, `stage end` and every `step` end with a `NEXT:` line naming the next required step and its exact command. A run that sits in one step for more than twice that step's expected minutes (`minutes` in flows/board.json) gets a WARNING on every command and in `finish-guard`; it never blocks, it means you are probably doing later steps' work undeclared.+Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it. `intake`, `start`, `prompt`, `stage end` and every `step` end with a `NEXT:` line naming the next required step and its exact command. A run that sits in one step for more than twice that step's expected minutes (`minutes` in flows/board.json) gets a WARNING on every command and in `finish-guard`; it never blocks, it means you are probably doing later steps' work undeclared. The warning waits while an `exec` of the run is still running (a long solve is the step's own work).  ### Before every final answer: executable finish guard @@ -54,6 +54,13 @@ A board with differential pairs, impedance control or length matching (PCIe, OCu 3. `stage end route`. 4. Record the numbers: `evidence add --kind calcs --file design/impedance.json` with the stackup, the trace width and gap per layer for the target impedance, the propagation delay per layer, and the skew per pair and per lane group after routing. +The plan JSON shape (full reference: docs/plan-schema.md):++- Top level: `{"nets": [entry, ...]}` or a bare `[entry, ...]`.+- Entry: `{"net": "VIN", "width": 0.25, "viaSize": 0.6, "viaDrill": 0.3, "paths": [[point, point, ...], ...]}`; `net` and `paths` (or one path as `points`) are required, the sizes are mm with those defaults.+- Point: a pad key `"U1.3"`, `[x, y]` in mm, `{"x": x, "y": y, "layer": "B.Cu"}` or `{"pad": "J1.1", "layer": "B.Cu"}`. The first point's layer (default F.Cu) starts the path.+- Via: a point whose layer differs from the current one drops a through via there and the path continues on that layer. For a via with copper on one layer only (a drop into a pour), end the path with that point at the same x, y as the point before it.+ If the binary should do this step itself, file it (see Give it back).  ## No step is skipped@@ -63,12 +70,22 @@ design, test-plan, sourcing, library, schematic, simulation, placement, routing, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` refuses while any required step before x is not done, and a step is done only when its own command ran during its latest visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land-moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `fusion silkscreen`, `fusion-export` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it+moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record`+(Fusion: `fusion silkscreen`), `export` (Fusion: `fusion export`) ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it draws an arc on the timeline, and every later step becomes a redo that must be done again in order. `finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never jump past it. +On the KiCad lane, `silkscreen-audit --input <audit.json> --out <result.json> --record` records the+silkscreen proof only when the audit passes and its inventory covers the board: every footprint by its+reference (id `R1`, or `R1:body`, `R1-value`), a via or hole item for every via, and at least 0.5 visible+labels per footprint. `export` writes the manufacturing set with kicad-cli (ADOM_AIFLOW_KICAD_CLI;+`adom-aiflow-kicad-cli-remote` in a container): Gerbers and drill in `<run>/manufacturing/gerbers/`, the+CPL and a BOM built from the board's footprints beside them. It reads every file back (copper layers from+the Gerber file functions, drill hits against vias and drilled pads, CPL and BOM references, sides and top+rotations against the placed parts, DNP and exclude attributes) and records the export proof only when all+of it matches. Bottom-side CPL rotations are counted, not compared; check them in the fab's preview.+ ## The timeline (show it, keep it up)  ### Automatic dashboard (all AI engines)@@ -175,6 +192,8 @@ Per-step clips stay RAW: the recording, its 10x cut and its motion-only action c  After `analyze current` and `analyze thermal`, do the analysis yourself and draw it: a current density map on the copper (a DC conduction solve on the board's filled polygons between each loaded net's terminals) and for heat two drawings: the heat flow in the copper layer by layer, like the current picture (a multi-layer solve, each chip's watts spreading on its layer and going down its vias, the vias drawn, each chip's split between layer, vias and bare dielectric), then the temperature rise over the whole board (the FR4 spreading); each a 1920x1080 PNG, then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old png>` when you redo one; it goes fullscreen into the video and onto the page under the step), then `tour nets --nets <the pour nets the hint lists>` under the same step so the clip ends on the copper the analysis judged, lit net by net. +Clip checks and cuts need ffmpeg and ffprobe. When either is missing, every clip stop says so by name and leaves the clip unjudged, and `recut` and `compose` refuse; install ffmpeg, then `recut` (it clears a blank flag an earlier check gave) and `compose`.+ No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence.  `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers.@@ -189,7 +208,7 @@ Deterministic checks that refuse, each an OK/ERROR with hints:  - **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed. - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional).-- **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not.+- **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the JLCPCB pack for the board's copper layer count: on 2 layers the shipped profile, installed with `rules install --profile jlcpcb --layers 2` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file, keeps it as `.kicad_dru.bak` and says what it replaced); on 4, 6 or more layers `rules use --profile jlcpcb --layers <n>` loads the pack from adom/pcb-design-rules (`rules install` refuses a layer count it does not ship). A layer suffix pins the count: `"rules": "jlcpcb-6L"`. When the spec has no `fab.rules`, the gate, finish and `rules check` use what `rules use` recorded in run.json `fabRules` (an intake run has no spec yet), and warn when the spec and `fabRules` disagree. `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith. - **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask. Publish only the `board-glb copper` output, never the raw step2glb GLB. @@ -264,10 +283,10 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched ### 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.+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; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); 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. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are. 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).+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. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred.+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 (text across an IC's pin rows always FAILs). 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+3−3
@@ -121,10 +121,10 @@ Whenever a component page is created or its model changes (here, or through mole ### 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.+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; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); 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. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are. 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).+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. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred.+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 (text across an IC's pin rows always FAILs). 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).  ## Verify the actual scaffold before claiming fit 
skills/aiflow-silkscreen/SKILL.md+1−1
@@ -26,7 +26,7 @@ Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and  ## Acceptance -Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list.+Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list. On the KiCad lane `silkscreen-audit --record` records the step proof only from a passing audit whose inventory names every footprint by its reference (`R1`, `R1:body`) and has a via or hole item for every via; `fusion silkscreen` records it on the Fusion lane.  ## Automated recording 
skills/aiflow-sourcing/SKILL.md+3−3
@@ -114,10 +114,10 @@ Whenever a component page is created or its model changes (here, or through mole ### 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.+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; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); 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. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are. 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).+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. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred.+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 (text across an IC's pin rows always FAILs). 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 
tests/rules-layers-workflow.pyadded+44
@@ -0,0 +1,44 @@+"""Issue #47: rules install honours --layers, never silently replaces another layer pack, and+rules check falls back to run.json fabRules and judges jlcpcb by the board's copper layers."""+import json,os,pathlib,subprocess,sys,tempfile+binary=str(pathlib.Path(sys.argv[1]).resolve())+SIX='(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "In2.Cu" signal) (3 "In3.Cu" signal) (4 "In4.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user)) (gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts")))\n'+PACK6='# adom-aiflow rules: jlcpcb 6L (from adom/pcb-design-rules)\n(version 1)\n(rule "x" (constraint clearance (min 0.1mm)))\n'+with tempfile.TemporaryDirectory(prefix='aiflow-rules-layers-') as td:+ p=pathlib.Path(td);run=p/'run';env=dict(os.environ,AIFLOW_DASHBOARD='off')+ def call(*args,inrun=True):+  pre=['--run',str(run)] if inrun else ['--run',str(p/'none')]+  return subprocess.run([binary,*pre,'--ai-thread','rules-layers',*args],env=env,capture_output=True,text=True,timeout=30)+ board=p/'b6.kicad_pcb';board.write_text(SIX);dru=board.with_suffix('.kicad_dru')+ # 1. install with --layers 6: no shipped pack, an error that points at `rules use`, nothing written+ r=call('rules','install','--profile','jlcpcb','--layers','6','--board',str(board),'--force',inrun=False)+ assert r.returncode!=0 and 'rules use --profile jlcpcb --layers 6' in (r.stdout+r.stderr),(r.stdout,r.stderr)+ assert not dru.exists()+ # without --layers the board's 6 copper layers decide: still not the 2L pack+ r=call('rules','install','--profile','jlcpcb','--board',str(board),inrun=False);assert r.returncode!=0 and not dru.exists()+ # --layers 2 on a 6-layer board is refused+ r=call('rules','install','--profile','jlcpcb','--layers','2','--board',str(board),'--force',inrun=False);assert r.returncode!=0 and not dru.exists()+ # 2. the 6L pack passes as jlcpcb and jlcpcb-6L; the 2L file fails with the right command+ dru.write_text(PACK6)+ for prof in ['jlcpcb','jlcpcb-6L']:+  r=call('rules','check','--profile',prof,'--board',str(board),inrun=False);assert r.returncode==0,(prof,r.stdout,r.stderr)+ # 3. install on a 2-layer board over a 6L pack: --force replaces it and says so, keeping a .bak+ two=p/'b2.kicad_pcb';two.write_text('(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (31 "B.Cu" signal)))\n');two.with_suffix('.kicad_dru').write_text(PACK6)+ r=call('rules','install','--profile','jlcpcb','--board',str(two),inrun=False);assert r.returncode!=0 and 'jlcpcb-6L' in (r.stdout+r.stderr)+ r=call('rules','install','--profile','jlcpcb','--board',str(two),'--force',inrun=False);out=r.stdout+r.stderr+ assert r.returncode==0 and 'jlcpcb-2L' in out and 'replaced the jlcpcb-6L pack' in out,out+ assert two.with_suffix('.kicad_dru.bak').read_text()==PACK6+ r=call('rules','check','--profile','jlcpcb','--board',str(two),inrun=False);assert r.returncode==0,(r.stdout,r.stderr)+ # 4. a run whose spec names no fab.rules falls back to run.json fabRules+ assert call('intake','--prompt-time','2026-10-10T00:00:00Z','--engine','test').returncode==0+ spec=p/'spec.json';spec.write_text(json.dumps({}))+ state=run/'run.json';d=json.loads(state.read_text());d['boards']=[str(board)];d['spec']=str(spec)+ d['fabRules']={'profile':'jlcpcb','slug':'jlcpcb','layers':6,'file':'jlcpcb-6L.kicad_dru'};state.write_text(json.dumps(d))+ r=call('rules','check');assert r.returncode==0,(r.stdout,r.stderr)+ o=json.loads(state.read_text())['outcomes']['rules'];assert o['pass'] and o['profile']=='jlcpcb',o+ dru.write_text('(version 1)\n# adom-aiflow rules profile: jlcpcb-2L\n(rule "x")\n')+ r=call('rules','check');assert r.returncode!=0 and '--layers 6' in (r.stdout+r.stderr),(r.stdout,r.stderr)+ # the spec disagreeing with fabRules warns+ spec.write_text(json.dumps({'fab':{'rules':'jlcpcb-2L'}}))+ r=call('rules','check');assert 'fabRules' in r.stderr,(r.stdout,r.stderr)+ print('PASS: install honours --layers and the board, refuses a missing shipped pack, reports a replaced pack; check reads fabRules, the board layer count and warns on disagreement.')
tools/kicad-cli-remote+53−1
@@ -4,6 +4,8 @@ #   kicad-cli-remote --version #   kicad-cli-remote pcb drc --format json --output <report.json> <board.kicad_pcb> #   kicad-cli-remote sch export netlist --format kicadsexpr --output <out.net> <design.kicad_sch>+#   kicad-cli-remote pcb export gerbers|drill --output <dir>/ [flags] <board.kicad_pcb>+#   kicad-cli-remote pcb export pos --output <file.csv> [flags] <board.kicad_pcb> # The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The # schematic travels with its sub-sheets, .kicad_pro, sym-lib-table and fp-lib-table, and every # ${KIPRJMOD} library the sym-lib-table names, in the same folders relative to the project.@@ -106,7 +108,57 @@ PY   exit 1 fi -[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'sch export netlist' and '--version' are supported" >&2; exit 2; }+if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" =~ ^(gerbers|drill|pos)$ ]]; then+  kind="$3"; shift 3+  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then+    echo "Usage: kicad-cli-remote pcb export $kind [flags] --output OUT INPUT_FILE (flags are passed to the desktop's native KiCad)"+    exit 0+  fi+  out=""; board=""; pass=()+  while [[ $# -gt 0 ]]; do+    case "$1" in+      --output|-o) out="$2"; shift 2;;+      --layers|-l|--format|--units|--side|--excellon-units|-u|--excellon-oval-format|--excellon-zeros-format|--drill-origin|--map-format|--gerber-precision|--precision|--common-layers|--cl) pass+=("$1" "$2"); shift 2;;+      --*) pass+=("$1"); shift;;+      *) board="$1"; shift;;+    esac+  done+  [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }+  find_cli; find_tmp+  job="aiflow-export-$(date +%s)-$$"+  dest="$RTMP/$job"+  base="$(basename "$board" .kicad_pcb)"+  files=("$(readlink -f "$board")")+  for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done+  args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"manufacturing export for adom-aiflow (native kicad-cli on the desktop)"}))' "$dest" "${files[@]}")+  adom-bridge --target "$T" send_files "$args" --ai-thread "$TH" >/dev/null+  # a folder output keeps its trailing separator, doubled before the closing quote so Windows does not read \" as a quote+  rout="$dest/out/"; [[ "$kind" == "pos" ]] && rout="$dest/out/$(basename "$out")"+  cmd=$(python3 -c 'import json,sys;cli,b,d,r,k=sys.argv[1:6];x=" ".join(sys.argv[6:]);r=r.replace("/",chr(92));r=r+chr(92) if r.endswith(chr(92)) else r;c=f"mkdir \"{d}/out\" 2>nul & \"{cli}\" pcb export {k} {x} --output \"{r}\" \"{b}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":600,"reason":"manufacturing export for adom-aiflow"}))' "$CLI" "$dest/$base.kicad_pcb" "$dest" "$rout" "$kind" "${pass[@]}")+  res=$(adom-bridge --target "$T" shell_execute "$cmd" --ai-thread "$TH")+  if [[ "$kind" == "pos" ]]; then+    names=("$(basename "$out")"); local_dir="$(dirname "$out")"+  else+    local_dir="${out%/}"+    lst=$(adom-bridge --target "$T" shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the exported manufacturing files"}))' "$dest/out")" --ai-thread "$TH")+    mapfile -t names < <(echo "$lst" | python3 -c 'import sys,json;[print(l.strip()) for l in json.load(sys.stdin).get("stdout","").splitlines() if l.strip()]')+  fi+  mkdir -p "$local_dir"+  code=0+  if [[ ${#names[@]} -eq 0 ]]; then code=1; else+    paths=(); for n in "${names[@]}"; do paths+=("$dest/out/$n"); done+    adom-bridge --target "$T" pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the manufacturing files back"}))' "$local_dir" "${paths[@]}")" --ai-thread "$TH" >/dev/null+    for n in "${names[@]}"; do [[ -f "$local_dir/$n" ]] || code=1; done+  fi+  cleanup_remote "$dest"+  if [[ $code -ne 0 ]]; then+    echo "kicad-cli-remote: the $kind export did not come back" >&2+    echo "$res" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true+  fi+  exit $code+fi++[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'pcb export gerbers|drill|pos', 'sch export netlist' and '--version' are supported" >&2; exit 2; } shift 2 if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then   echo "Usage: kicad-cli-remote pcb drc [--format json] [--output FILE] [--refill-zones] [--severity-all] INPUT_FILE"
tools/laser-etch/mark_check.py+55−6
@@ -4,7 +4,7 @@  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)+            band is near square, ratio < 1.15; across the pin rows of a two-row IC always FAILs)   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@@ -15,7 +15,10 @@ Checks (FAIL blocks; WARN is printed as a Hint):   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+The verdict is judged in the NORMALIZED pose (footprint frame after the stand-up turns), never the+mounted pose on a board; check.json "frame" records that frame, the source-to-normalized and+normalized-to-output turns, and whether model up (--up) and the marked face (--face) were supplied or+inferred (text up always comes from pin 1). 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 @@ -207,6 +210,47 @@ def render_top(step_text, a, plan, band, status, path, frame_label):     return path, None  +def _turns_text(turns):+    return ", ".join(f"{90 * t['quarterTurns']} deg about {t['axis'].upper()}" for t in turns if t["quarterTurns"] % 4) or "identity"+++def orientation_frame(plan, u_plan):+    """What 'upright' and 'pin 1 lower left' were judged in. The gate works in the NORMALIZED pose (the+    footprint frame: X right, Y up, Z up off the board, after the stand-up turns); the verdict says nothing+    about how the part is mounted on a board."""+    stand = [dict(axis=t["axis"], quarterTurns=t["quarterTurns"]) for t in plan["standUp"]]+    side = plan["band"].get("sideTurn")+    if side:+        stand = stand + [dict(axis=side["axis"], quarterTurns=side["quarterTurns"])]+    q_read = MP.quarter_turns_to(u_plan, (1, 0))+    q_out = plan["outline"]["poseRotZDeg"] // 90+    inp = plan.get("inputs") or {}+    up_sup = inp.get("upSupplied", str(plan.get("standUpSource", "")).startswith("--up"))+    face_sup = inp.get("faceSupplied")+    pin1 = plan["pin1"]["source"]+    fr = dict(+        reference="normalized pose: footprint frame, X right, Y up, Z up off the board (after the stand-up turns)"+                  + ("; the marked side wall turned to face +Z" if side else ""),+        sourceToNormalized=dict(turns=stand, text=_turns_text(stand), source=plan.get("standUpSource")),+        normalizedToOutput=dict(+            checkImage=dict(turns=[dict(axis="z", quarterTurns=q_read)], text=_turns_text([dict(axis="z", quarterTurns=q_read)]),+                            pose="standard reading pose: reading direction to +X, text up +Y, pin 1 lower left"),+            outline=dict(turns=[dict(axis="z", quarterTurns=q_out)], text=_turns_text([dict(axis="z", quarterTurns=q_out)]),+                         pose="iso outline from +X+Y+Z")),+        modelUp=dict(supplied=bool(up_sup), source=plan.get("standUpSource")),+        packageFront=dict(supplied=bool(face_sup) if face_sup is not None else None, face=plan["face"],+                          source=inp.get("faceSource") or ("unknown (plan predates 0.1.94)" if face_sup is None else None)),+        textUp=dict(supplied=False, vector=plan["readingDirection"]["textUp"], source="inferred from pin 1: " + pin1),+        mountedPose=False)+    pf = fr["packageFront"]+    fr["summary"] = (f"frame: normalized pose (source -> normalized: {fr['sourceToNormalized']['text']}, {plan.get('standUpSource')}; "+                     f"normalized -> check image: {fr['normalizedToOutput']['checkImage']['text']}, -> outline: {fr['normalizedToOutput']['outline']['text']}); "+                     f"model up {'supplied' if fr['modelUp']['supplied'] else 'inferred'}, "+                     f"face {plan['face']} {'supplied' if pf['supplied'] else 'inferred' if pf['supplied'] is False else 'source unknown'}, "+                     f"text up inferred from pin 1; not a mounted-pose check")+    return fr++ 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)@@ -223,7 +267,8 @@ def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_pat         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"])+        inp = plan.get("inputs") or {}+        plan = MP.plan(plan["step"], plan.get("footprint"), plan.get("mpn"), inp.get("face", plan["face"]), inp.get("up", "auto"))     s.flatten()     turns = [(t["axis"], t["quarterTurns"]) for t in plan["standUp"]]     if plan["band"]["sideTurn"]:@@ -248,8 +293,10 @@ def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_pat         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)+            rule = plan["longAxis"].get("source") or ("two-terminal" if plan["longAxis"].get("why", "").startswith("two-terminal") else None)+            sev = "FAIL" if aspect >= 1.15 or rule in ("two-terminal", "pin-rows") else "WARN"+            what = "across the pin rows (ICs are marked parallel to them)" if rule == "pin-rows" else f"across the {Hh:.2f} mm side"+            add("axis", sev, f"Text runs {what}; 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:@@ -316,7 +363,8 @@ def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_pat         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,+    frame = orientation_frame(plan, u_plan)+    rep = dict(schema="adom/mark-check@1", step=os.path.abspath(step_path), mpn=plan.get("mpn"), status=worst, checks=checks, frame=frame,                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:@@ -336,6 +384,7 @@ def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_pat         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)+            print(f"Hint: {frame['summary']}", file=sys.stderr)         for c in checks:             if c["status"] != "PASS":                 print(f"Hint: {c['status']} {c['check']}: {c['detail']}", file=sys.stderr)
tools/laser-etch/mark_plan.py+81−15
@@ -212,22 +212,65 @@ def _bbox_of(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."""+TOP_TOL = 2e-3+++def _planar_up(f, top_z=None, tol=0.99):+    """A flat face lying in a horizontal plane that faces up. Many vendor models store flat faces as+    B-spline surfaces (no normal from the service), so a face whose bbox has no Z thickness counts too.+    Horizontal is |n_z| > tol: KiCad's stock SOIC / TSSOP models store the top face reversed (normal+    0,0,-1), so the stored sign cannot decide up from down. A reversed face counts as facing up only+    when it lies at the top of the part's bbox (top_z): nothing can sit above it there. A downward+    face anywhere else (an underside, the bottom face) is never a marking face."""     b = f["bbox_mm"]-    if b[5] - b[2] >= 2e-3 or (b[3] - b[0]) < 1e-3 or (b[4] - b[1]) < 1e-3:+    if b[5] - b[2] >= TOP_TOL or (b[3] - b[0]) < 1e-3 or (b[4] - b[1]) < 1e-3:         return False     n = f.get("normal")-    return n is None or n[2] > tol+    if n is None or n[2] > tol:+        return True+    return n[2] < -tol and top_z is not None and b[5] >= top_z - TOP_TOL+++def _reversed_up(f, top_z):+    n = f.get("normal")+    return bool(n is not None and n[2] < -0.99 and _planar_up(f, top_z))  -def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry"):+def pin_rows(pads, tol=0.02):+    """Two parallel rows of pins (SOIC, TSSOP, SOT-23-5, DIP...): the axis the rows run along, or None.+    Pads whose number repeats (exposed-pad sub-pads, thermal vias) and unnumbered pads are left out;+    at most two other pad numbers may remain, and only between the rows (an exposed pad)."""+    from collections import Counter+    cnt = Counter(p["num"] for p in pads)+    sig = [p for p in pads if p["num"] and cnt[p["num"]] == 1]+    if len(sig) < 4:+        return None+    for axis, key, run in (("y", "x", "y"), ("x", "y", "x")):+        groups = []+        for p in sorted(sig, key=lambda p: p[key]):+            if groups and abs(p[key] - groups[-1][-1][key]) <= tol:+                groups[-1].append(p)+            else:+                groups.append([p])+        rows = [g for g in groups if len(g) >= 2]+        if len(rows) != 2:+            continue+        rest = [p for p in sig if not any(p is q for g in rows for q in g)]+        lo, hi = sorted(g[0][key] for g in rows)+        if len(rest) > 2 or any(not lo < p[key] < hi for p in rest):+            continue+        return dict(axis=axis, pinsPerRow=[len(g) for g in rows], rowSpacingMm=round(hi - lo, 4), exposed=len(rest))+    return None+++def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry", axis=None):+    """The usable band on the top face. `axis` ("x"/"y") fixes the mark axis in advance (pin rows): end+    trims and keep-outs then shorten the band along that axis and never swap it."""     faces = _faces(meta, mark_ids)     bb = _bbox_of(faces)     cx, cy = (bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2     H = bb[5] - bb[2]-    cand = [f for f in faces if _planar_up(f) and f["bbox_mm"][5] >= bb[5] - max(0.35 * H, 0.05)]+    cand = [f for f in faces if _planar_up(f, bb[5]) and f["bbox_mm"][5] >= bb[5] - max(0.35 * H, 0.05)]     if not cand:         raise SystemExit("mark_plan: no flat upward face near the top of the part; is it standing up? (pass --up y for a Y-up model)")     def contains(f, x, y, pad=1e-6):@@ -242,7 +285,8 @@ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry         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)+    long_x = (axis == "x") if axis in ("x", "y") else (x1 - x0) >= (y1 - y0)+    reversed_top = _reversed_up(face, bb[5])     trims, keepouts = [], []     # terminations / raised features at the ends: faces at or above the band, overlapping it at an end     fa = face["area_mm2"]@@ -312,7 +356,7 @@ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry         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))+                trimmed=[t[0] for t in trims], keepouts=merged, bodyBox=bb, vendorGlyphFaces=len(glyphs), reversedNormal=reversed_top)   def side_band(meta, mark_ids, up_vec, pick="high"):@@ -320,7 +364,7 @@ def side_band(meta, mark_ids, up_vec, pick="high"):     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]+    cand = [f for f in faces if _planar_up(f, top) and top - f["bbox_mm"][5] < TOP_TOL and f["area_mm2"] > 1e-6]     if not cand:         raise SystemExit("mark_plan: the chosen side has no flat face")     big = max(f["area_mm2"] for f in cand)@@ -365,14 +409,19 @@ def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick=     bb = _bbox_of(faces)     cx, cy = (bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2     hints, warns = [], []+    face_arg = face     if face == "auto":         face = "side" if is_led(fp, meta, mpn, [n for _, n in raw.products()]) else "top"         if face == "side":             hints.append("LED detected (footprint/model name): the mark goes on the front long side wall, the window stays clear; --face top overrides")     if marks:         warns.append(f"the STEP already carries a mark ({', '.join(marks)}); etch from the plain model, or let adom-aiflow-etch replace it (it does by default)")+    rows = None if two_terminal else pin_rows(pads)     # ---- axis from the top face (the footprint's view) -----    band_top = top_band(meta, mark_ids, pads, (p1["x"], p1["y"]) if p1 else None, two_terminal, terminations)+    band_top = top_band(meta, mark_ids, pads, (p1["x"], p1["y"]) if p1 else None, two_terminal, terminations,+                        axis=rows["axis"] if rows and face == "top" else None)+    if band_top.get("reversedNormal"):+        hints.append("the top face is stored with a reversed normal (0,0,-1), as in KiCad's stock SOIC / TSSOP models; it is the top face by position (top of the part's bbox)")     x0, y0, x1, y1 = band_top["region"]     vendor_mark = band_top["vendorGlyphFaces"] >= 8     if vendor_mark:@@ -382,28 +431,41 @@ def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick=         p2 = next(p for p in pads if p["num"] != p1["num"])         axis = "x" if abs(p2["x"] - p1["x"]) >= abs(p2["y"] - p1["y"]) else "y"         axis_why = "two-terminal part: along the body, pad 1 to pad 2 (between the terminations)"+        axis_src = "two-terminal"         full_len = (bb[3] - bb[0]) if axis == "x" else (bb[4] - bb[1])         if not band_top["terminations"] and (Lx if axis == "x" else Ly) > 0.97 * full_len:             hints.append("one flat face covers the whole top: if this part's terminations wrap onto the top (chip R/C/L), rerun with --terminations pads")+    elif rows:+        # two rows of pins: the mark runs parallel to the rows; a pin-1 dimple only shortens the band+        axis = rows["axis"]+        axis_why = (f"two pin rows ({' + '.join(str(n) for n in rows['pinsPerRow'])} pins, {rows['rowSpacingMm']:.2f} mm apart): "+                    f"parallel to the pin rows (band {Lx if axis == 'x' else Ly:.2f} mm along, {Ly if axis == 'x' else Lx:.2f} mm across)")+        axis_src = "pin-rows"     elif abs(Lx - Ly) / max(Lx, Ly) < 0.08 and p1 and len(pads) > 2:         # square body: run along the pin-1 row         p2 = next((p for p in pads if p["num"] == "2"), None)         axis = "x" if p2 and abs(p2["x"] - p1["x"]) > abs(p2["y"] - p1["y"]) else "y"         axis_why = "square body: along the pin-1 row"+        axis_src = "pin-1-row"     else:         axis = "x" if Lx >= Ly else "y"         axis_why = f"longest usable top dimension ({max(Lx, Ly):.2f} mm vs {min(Lx, Ly):.2f} mm)"+        axis_src = "longest"     if face == "side":         # side marks run along the part's long horizontal dimension, on the long wall         axis = "x" if (bb[3] - bb[0]) >= (bb[4] - bb[1]) else "y"         axis_why = f"long side wall ({max(bb[3] - bb[0], bb[4] - bb[1]):.2f} mm)"+        axis_src = "side-wall"     override = None     if along in ("x", "y"):         if along != axis:             override = along-            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")+            if axis_src == "pin-rows" and face == "top":+                warns.append(f"--along {along} puts the text ACROSS the pin rows; ICs are marked parallel to the pin rows (the planner's {axis.upper()})")+            else:+                warns.append(f"--along {along} puts the text across the {'short' if face == 'top' else 'other'} side "+                             f"({(Ly if along == 'y' else Lx) if face == 'top' else 0:.2f} mm instead of {max(Lx, Ly) if face == 'top' else 0:.2f} mm); "+                             "only do this to keep a polarity stripe or printed band clear")         axis = along     a = [1.0, 0.0, 0.0] if axis == "x" else [0.0, 1.0, 0.0]     # ---- reading direction from pin 1 ----@@ -433,6 +495,10 @@ def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick=         corner = "unknown"     out = dict(schema=SCHEMA, mpn=mpn, step=os.path.abspath(step_path), footprint=os.path.abspath(footprint) if footprint else None,                standUp=[dict(axis=ax, quarterTurns=qq) for ax, qq in turns], standUpSource=up_src,+               inputs=dict(up=up, face=face_arg, along=along,+                           upSupplied=up in ("z", "y"), faceSupplied=face_arg in ("top", "side"),+                           faceSource=(f"--face {face_arg}" if face_arg in ("top", "side") else+                                       "inferred: LED detected, front long side wall" if face == "side" else "inferred: top face")),                face=face, pin1=dict(source=pin1_src, xyMm=[round(p1["x"], 4), round(p1["y"], 4)] if p1 else None, corner=corner),                existingMarks=marks, vendorMarking=None)     if face == "top":@@ -464,7 +530,7 @@ def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick=     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),+        longAxis=dict(axis=axis, vector=a, why=axis_why, source=axis_src, override=override, pinRows=rows),         readingDirection=dict(vector=u, angleDeg=angle_deg(u), textUp=v,                               rule="pin 1 / pad 1 at the start (left) of the text, and below it when it sits on a side"),         band=dict(regionMm=[round(t, 4) for t in band["region"]], zMm=round(band["z"], 4), lengthMm=round(L, 4), heightMm=round(Hh, 4),
tools/laser-etch/step_text.py+96−4
@@ -254,8 +254,6 @@ class Step:         """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]@@ -269,10 +267,14 @@ class Step:             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:+            if c in parent:                 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)+        # Check all occurrences before isolating frames or modifying any geometry.+        # The same child can appear twice in ONE parent (e.g. opposing capacitor leads).+        frames = self._isolate_frames()+        fents = self._frame_ents(frames)         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@@ -538,14 +540,104 @@ class Step:             out.append(c); i += 1         return "".join(out) +    # ---- assembly building (service output without an assembly) ---------------------------------+    @staticmethod+    def _q(name):+        return name.replace("'", "''")++    def _new_product(self, name, like_pid):+        """A new product (PRODUCT .. PRODUCT_DEFINITION_SHAPE) in the same contexts as `like_pid`."""+        pc = self.refs(like_pid)[-1]+        pdc = self.refs(self._pd_of(like_pid))[-1]+        n = self._q(name)+        pid = self._add(f"PRODUCT('{n}','{n}','',(#{pc}))")+        pdf = self._add(f"PRODUCT_DEFINITION_FORMATION('','',#{pid})")+        pd = self._add(f"PRODUCT_DEFINITION('design','',#{pdf},#{pdc})")+        pds = self._add(f"PRODUCT_DEFINITION_SHAPE('','',#{pd})")+        return pid, pd, pds++    def _new_axis(self):+        o = self._add("CARTESIAN_POINT('',(0.,0.,0.))"); z = self._add("DIRECTION('',(0.,0.,1.))"); x = self._add("DIRECTION('',(1.,0.,0.))")+        return self._add(f"AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})")++    def _frame_rep(self, pds, ctx):+        """An empty SHAPE_REPRESENTATION (just its frame) as the shape of an assembly product."""+        ax = self._new_axis()+        rep = self._add(f"SHAPE_REPRESENTATION('',(#{ax}),#{ctx})")+        self._add(f"SHAPE_DEFINITION_REPRESENTATION(#{pds},#{rep})")+        return rep, ax++    def _rep_of_pd(self, pd):+        pds = [k for k in self.rev().get(pd, []) if self.kind(k) == "PRODUCT_DEFINITION_SHAPE"]+        sdr = [k for p in pds for k in self.rev().get(p, []) if self.kind(k) == "SHAPE_DEFINITION_REPRESENTATION"]+        return (pds[0] if pds else None), (sdr[0] if sdr else None), (self.refs(sdr[0])[-1] if sdr else None)++    def _rep_axis(self, rep):+        fr = [x for x in self._rep_items(rep) if self.kind(x) == "AXIS2_PLACEMENT_3D"]+        if fr:+            return fr[0]+        ax = self._new_axis()+        b = self.body[rep]+        i = b.index("(", b.index("(") + 1)+        self.body[rep] = b[: i + 1] + f"#{ax}," + b[i + 1:]+        self._rev = None+        return ax++    def _link(self, parent_pd, parent_rep, parent_ax, child_pd, child_rep, child_ax, name):+        """Place child under parent at the identity (NAUO + placement + context-dependent shape rep)."""+        n = self._q(name)+        nauo = self._add(f"NEXT_ASSEMBLY_USAGE_OCCURRENCE('{n}','{n}','',#{parent_pd},#{child_pd},$)")+        pds = self._add(f"PRODUCT_DEFINITION_SHAPE('Placement','Placement of an item',#{nauo})")+        idt = self._add(f"ITEM_DEFINED_TRANSFORMATION('','',#{child_ax},#{parent_ax})")+        rr = self._add(f"( REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep}) "+                       f"REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{idt}) SHAPE_REPRESENTATION_RELATIONSHIP() )")+        self._add(f"CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{pds})")+        return nauo++    def _assemble_flat_roots(self, under="Adom.Chip", mark="Adom.LaserEtch", root_name="adom_mark_proxy"):+        """The /laser-etch service (2026-10) returns the input body renamed `under` and the mark as a+        second, unlinked root product. Rebuild the assembly the etch expects (and the service used to+        return): root `root_name` -> [`under` -> proxy body, `mark`], all at the identity. The body's+        own representation, geometry and colours are moved to the new proxy product untouched."""+        if not getattr(self, "_flat", False):+            self.flatten()+        roots = dict((n, p) for p, n in self._root_products())+        if under not in roots or mark not in roots:+            raise SystemExit(f"step_text: expected {under} and {mark} in the service output, got {sorted(roots)}")+        chip, etch = roots[under], roots[mark]+        chip_pd, etch_pd = self._pd_of(chip), self._pd_of(etch)+        chip_pds, chip_sdr, body_rep = self._rep_of_pd(chip_pd)+        _, _, etch_rep = self._rep_of_pd(etch_pd)+        if body_rep is None or etch_rep is None:+            raise SystemExit(f"step_text: {under} or {mark} has no shape representation")+        ctx = self.refs(body_rep)[-1]+        # the body moves to its own product; Adom.Chip becomes an assembly around it+        _, body_pd, body_pds = self._new_product(root_name, chip)+        self.body[chip_sdr] = re.sub(rf"#{chip_pds}(?!\d)", f"#{body_pds}", self.body[chip_sdr], count=1)+        self._rev = None+        chip_rep, chip_ax = self._frame_rep(chip_pds, ctx)+        self._link(chip_pd, chip_rep, chip_ax, body_pd, body_rep, self._rep_axis(body_rep), "body")+        # a root above both+        _, root_pd, root_pds = self._new_product(root_name, chip)+        root_rep, root_ax = self._frame_rep(root_pds, ctx)+        self._link(root_pd, root_rep, root_ax, chip_pd, chip_rep, self._rep_axis(chip_rep), under)+        self._link(root_pd, root_rep, root_ax, etch_pd, etch_rep, self._rep_axis(etch_rep), mark)+        self._rev = None+        return self+     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."""+        product and everything below it, colours included). Both files must be flattened. Accepts the+        service's old output (the proxy as `under`'s child, under a root next to Adom.LaserEtch) and its+        current one (`under` and Adom.LaserEtch as two unlinked roots, `under` carrying the proxy body)."""         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 not nauo and any(n == under for _, n in self._root_products()):+            self._assemble_flat_roots(under)+            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]
tools/laser-etch/test_mark_plan_rows.pyadded+199
@@ -0,0 +1,199 @@+"""Issue #45 and #41 regressions, offline (synthetic step_meta and footprints; no service, no KiCad).++  reversed top   KiCad's stock SOIC / TSSOP models store the top face reversed (normal 0,0,-1): it is still+                 the top face (by bbox position); a downward face below the top never is.+  pin rows       TSSOP-16 (4.4 x 5.0 body, rows along Y): the pin-1 dimple trims the band to 4.15 x 3.78 mm,+                 which used to swing the mark across the pin rows; the axis now follows the rows and pin 1+                 lands lower left in the standard reading pose.+  frame          the gate's report names the normalized frame and what was supplied or inferred.+Run: python3 -m unittest -v test_mark_plan_rows   (from tools/laser-etch)"""+import os, sys, tempfile, unittest++HERE = os.path.dirname(os.path.abspath(__file__))+sys.path.insert(0, HERE)+import mark_plan as MP+import mark_check as MC+++def face(fid, bbox, normal=None, area=None):+    if area is None:+        area = max(bbox[3] - bbox[0], 1e-9) * max(bbox[4] - bbox[1], 1e-9)+    return dict(face_id=fid, product_id=0, surface_type=None, bbox_mm=bbox, area_mm2=area, normal=normal)+++def tssop16_meta(top_normal=(0.0, 0.0, -1.0)):+    """The faces that matter in Package_SO.3dshapes/TSSOP-16_4.4x5mm_P0.65mm.step (as /step-meta reports+    them): body 4.4 x 5.0, chamfered top 4.15 x 4.75 at z 1.1 stored reversed, the pin-1 dimple near+    pin 1 (upper left), the moulding step at z 0.675, leads out to x = +-3.2 and the bottom at z 0."""+    n = list(top_normal) if top_normal else None+    f = [+        face("top", [-2.075, -2.375, 1.1, 2.075, 2.375, 1.1], n, 19.517),+        face("dimple", [-1.625, 1.425, 1.1, -1.125, 1.925, 1.1], n, 0.196),+        face("chamfer+x", [2.075, -2.45, 0.675, 2.15, 2.45, 1.1], None, 2.082),+        face("chamfer-x", [-2.15, -2.45, 0.675, -2.075, 2.45, 1.1], None, 2.082),+        face("chamfer+y", [-2.15, 2.375, 0.675, 2.15, 2.45, 1.1], None, 1.823),+        face("chamfer-y", [-2.15, -2.45, 0.675, 2.15, -2.375, 1.1], None, 1.823),+        face("step+y", [-2.2, 2.405, 0.675, 2.2, 2.5, 0.675], [0.0, 0.0, 1.0], 0.225),+        face("bottom", [-2.2, -2.5, 0.0, 2.2, 2.5, 0.0], [0.0, 0.0, -1.0], 22.0),+        face("lead-x", [-3.2, -2.5, 0.0, -2.2, 2.5, 0.15], None, 0.5),+        face("lead+x", [2.2, -2.5, 0.0, 3.2, 2.5, 0.15], None, 0.5),+    ]+    return dict(faces=f, products=[dict(id=0, name="TSSOP-16")], up_axis_hint="z")+++def tssop16_pads(exposed=False, vias=0):+    pads = []+    for i in range(8):+        pads.append(dict(num=str(i + 1), x=-2.8625, y=2.275 - 0.65 * i, w=1.225, h=0.4))+        pads.append(dict(num=str(16 - i), x=2.8625, y=2.275 - 0.65 * i, w=1.225, h=0.4))+    if exposed:+        pads.append(dict(num="17", x=0.0, y=0.0, w=2.0, h=3.0))+        for k in range(vias):+            pads.append(dict(num="17", x=-0.5 + 0.5 * (k % 3), y=-1.0 + 1.0 * (k // 3), w=0.3, h=0.3))+    return pads+++def kicad_mod(pads, rot=(0, 0, 0)):+    """A footprint file from pads given in the 3-D frame (Y up): KiCad stores Y down."""+    s = ['(footprint "SYNTH"']+    for p in pads:+        s.append(f'  (pad "{p["num"]}" smd roundrect (at {p["x"]} {-p["y"]}) (size {p["w"]} {p["h"]}) (layers "F.Cu"))')+    s.append(f'  (model "x.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz {rot[0]} {rot[1]} {rot[2]})))')+    s.append(")")+    return "\n".join(s) + "\n"+++class PlanOffline:+    """plan() with the service replaced by a fixed step_meta and the proxy box as the STEP text."""+    def __init__(self, meta):+        self.meta = meta++    def __enter__(self):+        self._orig = MP.SVC.step_meta+        MP.SVC.step_meta = lambda text, job="": self.meta+        return self++    def __exit__(self, *a):+        MP.SVC.step_meta = self._orig+++def run_plan(meta, pads, **kw):+    with tempfile.TemporaryDirectory(prefix="markplan-") as d:+        fp = os.path.join(d, "synth.kicad_mod")+        with open(fp, "w") as fh:+            fh.write(kicad_mod(pads))+        with PlanOffline(meta):+            return MP.plan(os.path.join(HERE, "proxy_box.step"), fp, kw.pop("mpn", "74HC595PW"), **kw)+++class ReversedTopFace(unittest.TestCase):+    def test_reversed_top_is_up_by_position(self):+        m = tssop16_meta()+        top = max(f["bbox_mm"][5] for f in m["faces"])+        by = {f["face_id"]: f for f in m["faces"]}+        self.assertTrue(MP._planar_up(by["top"], top))+        self.assertTrue(MP._planar_up(by["dimple"], top))+        self.assertTrue(MP._planar_up(by["step+y"], top))          # stored +Z, below the top: still up+        self.assertFalse(MP._planar_up(by["bottom"], top))         # the bottom face faces down+        self.assertFalse(MP._planar_up(by["top"]))                 # no top height given: the sign decides+        under = face("underside", [-1, -1, 0.9, 1, 1, 0.9], [0.0, 0.0, -1.0])+        self.assertFalse(MP._planar_up(under, top))                # a downward face below the top is not a marking face++    def test_band_found_on_reversed_top(self):+        b = MP.top_band(tssop16_meta(), [], tssop16_pads(), (-2.8625, 2.275), False)+        self.assertEqual(b["faceId"], "top")+        self.assertTrue(b["reversedNormal"])+        self.assertAlmostEqual(b["z"], 1.1)++    def test_normal_top_unchanged(self):+        b = MP.top_band(tssop16_meta((0.0, 0.0, 1.0)), [], tssop16_pads(), (-2.8625, 2.275), False)+        self.assertEqual(b["faceId"], "top")+        self.assertFalse(b["reversedNormal"])++    def test_plan_reversed_top_and_face_top(self):+        for f in ("auto", "top"):+            p = run_plan(tssop16_meta(), tssop16_pads(), face=f)+            self.assertEqual(p["face"], "top")+            self.assertTrue(any("reversed normal" in h for h in p["hints"]))++    def test_side_band_reversed(self):+        m = dict(faces=[face("wall", [-2, -0.5, 1.0, 2, 0.5, 1.0], [0.0, 0.0, -1.0]),+                        face("base", [-2, -0.5, 0.0, 2, 0.5, 0.0], [0.0, 0.0, -1.0])])+        b = MP.side_band(m, [], [0, 1, 0])+        self.assertEqual(b["faceId"], "wall")+++class PinRows(unittest.TestCase):+    def test_rows_detected(self):+        r = MP.pin_rows(tssop16_pads())+        self.assertEqual(r["axis"], "y")+        self.assertEqual(r["pinsPerRow"], [8, 8])+        self.assertEqual(MP.pin_rows(tssop16_pads(exposed=True, vias=6))["axis"], "y")+        turned = [dict(p, x=p["y"], y=-p["x"]) for p in tssop16_pads()]+        self.assertEqual(MP.pin_rows(turned)["axis"], "x")++    def test_not_rows(self):+        quad = []                                     # QFN-16: four sides of four+        for i in range(4):+            c = -0.75 + 0.5 * i+            quad += [dict(num=str(i + 1), x=-1.5, y=-c, w=0.6, h=0.25), dict(num=str(i + 5), x=c, y=-1.5, w=0.25, h=0.6),+                     dict(num=str(i + 9), x=1.5, y=c, w=0.6, h=0.25), dict(num=str(i + 13), x=-c, y=1.5, w=0.25, h=0.6)]+        self.assertIsNone(MP.pin_rows(quad))+        sot223 = [dict(num=str(i + 1), x=-2.3 + 2.3 * i, y=-3.15, w=0.95, h=2.0) for i in range(3)] + [dict(num="4", x=0, y=3.15, w=3.25, h=2.0)]+        self.assertIsNone(MP.pin_rows(sot223))+        self.assertIsNone(MP.pin_rows([dict(num="1", x=-1, y=0, w=1, h=1), dict(num="2", x=1, y=0, w=1, h=1)]))++    def test_tssop16_marks_along_rows_pin1_lower_left(self):+        p = run_plan(tssop16_meta(), tssop16_pads())+        self.assertEqual(p["longAxis"]["axis"], "y")+        self.assertEqual(p["longAxis"]["source"], "pin-rows")+        x0, y0, x1, y1 = p["band"]["regionMm"]+        self.assertAlmostEqual(x1 - x0, 4.15, places=2)              # the dimple trims the length ...+        self.assertAlmostEqual(y1 - y0, 3.78, places=2)+        self.assertAlmostEqual(p["band"]["lengthMm"], y1 - y0, places=3)  # ... but the mark still runs along Y+        self.assertEqual(p["pin1"]["corner"], "left/lower")+        self.assertEqual(p["readingDirection"]["vector"][:2], [0.0, -1.0])+        self.assertFalse(any("UPPER" in w for w in p["warnings"]))+        # standard reading pose: turn the reading direction to +X; pin 1 must be lower left+        q = MP.quarter_turns_to(p["readingDirection"]["vector"], (1, 0))+        px, py, _ = MP._rot([-2.8625, 2.275, 0], "z", q)+        self.assertLess(px, 0); self.assertLess(py, 0)++    def test_along_agrees_with_planner(self):+        p = run_plan(tssop16_meta(), tssop16_pads(), along="y")+        self.assertIsNone(p["longAxis"]["override"])+        self.assertEqual(p["warnings"], [])+        p = run_plan(tssop16_meta(), tssop16_pads(), along="x")+        self.assertEqual(p["longAxis"]["override"], "x")+        self.assertTrue(any("ACROSS the pin rows" in w for w in p["warnings"]))++    def test_no_footprint_falls_back_to_longest(self):+        meta = tssop16_meta()+        with PlanOffline(meta):+            p = MP.plan(os.path.join(HERE, "proxy_box.step"), None, "74HC595PW")+        self.assertEqual(p["longAxis"]["source"], "longest")+++class Frame(unittest.TestCase):+    def test_frame_record(self):+        p = run_plan(tssop16_meta(), tssop16_pads())+        fr = MC.orientation_frame(p, p["readingDirection"]["vector"])+        self.assertIn("normalized pose", fr["reference"])+        self.assertEqual(fr["sourceToNormalized"]["text"], "identity")+        self.assertEqual(fr["normalizedToOutput"]["checkImage"]["turns"], [dict(axis="z", quarterTurns=1)])+        self.assertFalse(fr["modelUp"]["supplied"]); self.assertFalse(fr["packageFront"]["supplied"])+        self.assertFalse(fr["textUp"]["supplied"]); self.assertFalse(fr["mountedPose"])+        p2 = run_plan(tssop16_meta(), tssop16_pads(), face="top", up="z")+        fr2 = MC.orientation_frame(p2, p2["readingDirection"]["vector"])+        self.assertTrue(fr2["modelUp"]["supplied"]); self.assertTrue(fr2["packageFront"]["supplied"])+        self.assertIn("supplied", fr2["summary"])++    def test_frame_old_plan(self):+        p = run_plan(tssop16_meta(), tssop16_pads())+        p.pop("inputs")+        fr = MC.orientation_frame(p, p["readingDirection"]["vector"])+        self.assertIsNone(fr["packageFront"]["supplied"])+++if __name__ == "__main__":+    unittest.main()
tools/laser-etch/test_step_graft.pyadded+101
@@ -0,0 +1,101 @@+"""graft() on both shapes of the /laser-etch service output (synthetic fixtures built from proxy_box.step):+  flat   (service since 2026-10) Adom.Chip (carrying the proxy body) and Adom.LaserEtch as two unlinked roots+  nested (earlier service)       root -> [Adom.Chip -> proxy body, Adom.LaserEtch]+Either way the result is the documented one: one root named after the model, Adom.Chip -> the model+(colours kept), Adom.LaserEtch beside it with its geometry where it was, the proxy body and its colour gone.+Run: python3 -m unittest -v test_step_graft   (from tools/laser-etch; offline)"""+import os, re, sys, unittest++HERE = os.path.dirname(os.path.abspath(__file__))+sys.path.insert(0, HERE)+import step_text as ST++PROXY = os.path.join(HERE, "proxy_box.step")+CHIP_RGB, MARK_RGB, PART_RGB = (0.9, 0.1, 0.1), (0.95, 0.95, 0.95), (0.2, 0.3, 0.4)+++def colour(s, rgb):+    """Give the file's (single) brep a colour, the way OpenCascade writes it."""+    brep = next(k for k in s.order if s.kind(k) == "MANIFOLD_SOLID_BREP")+    rep = next(k for k in s.order if s.kind(k) == "ADVANCED_BREP_SHAPE_REPRESENTATION")+    ctx = s.refs(rep)[-1]+    a = s._add(f"COLOUR_RGB('',{ST.fmt(rgb[0])},{ST.fmt(rgb[1])},{ST.fmt(rgb[2])})")+    b = s._add(f"FILL_AREA_STYLE_COLOUR('',#{a})"); c = s._add(f"FILL_AREA_STYLE('',(#{b}))")+    d = s._add(f"SURFACE_STYLE_FILL_AREA(#{c})"); e = s._add(f"SURFACE_SIDE_STYLE('',(#{d}))")+    f = s._add(f"SURFACE_STYLE_USAGE(.BOTH.,#{e})"); g = s._add(f"PRESENTATION_STYLE_ASSIGNMENT((#{f}))")+    h = s._add(f"STYLED_ITEM('color',(#{g}),#{brep})")+    s._add(f"MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',(#{h}),#{ctx})")+    return s+++def part(name, box, rgb):+    s = ST.Step(PROXY).scale_axes(*box)+    s.rename_product("adom_mark_proxy", name)+    return ST.Step(colour(s, rgb).text)+++def flat_service_output():+    chip = part("Adom.Chip", (-1, -0.5, 0.0, 2, 1, 0.5), CHIP_RGB)+    mark = part("Adom.LaserEtch", (-0.6, -0.2, 0.505, 1.2, 0.4, 0.0), MARK_RGB)+    off = max(chip.order) + 10+    for k in mark.order:+        chip.body[k + off] = chip._remap(mark.body[k], off); chip.lead[k + off] = ""; chip.order.append(k + off)+    chip._rev = None+    return ST.Step(chip.text)+++def nested_service_output():+    s = flat_service_output(); s.flatten(); s._assemble_flat_roots()+    return ST.Step(s.text)+++def colours(s):+    return set(tuple(round(float(v), 3) for v in re.findall(r"[-\d.E+]+", s.body[k].split(",", 1)[1])) for k in s.order if s.kind(k) == "COLOUR_RGB")+++def tree(s):+    name = lambda pd: next(n for p, n in s.products() if s._pd_of(p) == pd)+    return sorted(f"{name(s.refs(k)[0])}>{name(s.refs(k)[1])}" for k in s.order if s.kind(k) == "NEXT_ASSEMBLY_USAGE_OCCURRENCE")+++def mark_z(s):+    ents = s.product_entities("Adom.LaserEtch") - s._frame_ents(s.rep_frames())   # geometry, not placement frames+    zs = [s.coords(k)[2] for k in ents if s.kind(k) == "CARTESIAN_POINT"]+    return round(min(zs), 6), round(max(zs), 6)+++class Graft(unittest.TestCase):+    def check(self, e):+        e.flatten()+        before = mark_z(e)+        donor = part("SOIC-8", (-2, -2.5, 0.0, 4, 5, 0.5), PART_RGB); donor.flatten()+        e.graft(donor, "Adom.Chip")+        e.rotate90("z", 1); e.rotate90("z", -1)+        out = ST.Step(e.text); out.flatten()+        self.assertEqual([n for _, n in out._root_products()], ["SOIC-8"])+        self.assertEqual(tree(out), ["Adom.Chip>SOIC-8", "SOIC-8>Adom.Chip", "SOIC-8>Adom.LaserEtch"])+        self.assertNotIn("adom_mark_proxy", out.text)+        self.assertEqual(colours(out), {PART_RGB, MARK_RGB})          # model colour kept, proxy colour gone+        self.assertEqual(mark_z(out), before)                          # the mark stays flat where it was+        self.assertEqual(sum(1 for k in out.order if out.kind(k) == "MANIFOLD_SOLID_BREP"), 2)++    def test_flat_roots(self):+        s = flat_service_output()+        self.assertEqual(sorted(n for _, n in s._root_products()), ["Adom.Chip", "Adom.LaserEtch"])+        self.check(s)++    def test_nested(self):+        s = nested_service_output()+        self.assertEqual([n for _, n in s._root_products()], ["adom_mark_proxy"])+        self.assertIn("Adom.Chip>adom_mark_proxy", tree(s))+        self.check(s)++    def test_missing_mark_refused(self):+        s = part("Adom.Chip", (-1, -0.5, 0.0, 2, 1, 0.5), CHIP_RGB); s.flatten()+        donor = part("P", (0, 0, 0, 1, 1, 1), PART_RGB); donor.flatten()+        with self.assertRaisesRegex(SystemExit, "Adom.LaserEtch"):+            s.graft(donor)+++if __name__ == "__main__":+    unittest.main()
tools/laser-etch/test_step_instances.pyadded+47
@@ -0,0 +1,47 @@+"""The same child repeated inside one parent must never be flattened onto one placement."""+import unittest+from step_text import Step++HEADER="ISO-10303-21; HEADER; ENDSEC; DATA;\n"+TAIL="\nENDSEC; END-ISO-10303-21;"++def fixture(two=True, other_parent=False):+    s="""+#1=CARTESIAN_POINT('',(0.,0.,0.));+#2=DIRECTION('',(0.,0.,1.));+#3=DIRECTION('',(1.,0.,0.));+#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);+#5=CARTESIAN_POINT('',(-2.,0.,0.));+#6=AXIS2_PLACEMENT_3D('',#5,#2,#3);+#7=CARTESIAN_POINT('',(2.,0.,0.));+#8=AXIS2_PLACEMENT_3D('',#7,#2,#3);+#9=CARTESIAN_POINT('',(0.,1.,0.));+#10=SHAPE_REPRESENTATION('',(#4,#9),#99);+#11=SHAPE_REPRESENTATION('',(#4,#6,#8),#99);+#12=ITEM_DEFINED_TRANSFORMATION('','',#4,#6);+#13=(REPRESENTATION_RELATIONSHIP('','',#10,#11) REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#12) SHAPE_REPRESENTATION_RELATIONSHIP());+#99=GEOMETRIC_REPRESENTATION_CONTEXT(3);+"""+    if two:+        s+="""+#14=ITEM_DEFINED_TRANSFORMATION('','',#4,#8);+#15=(REPRESENTATION_RELATIONSHIP('','',#10,#PARENT) REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#14) SHAPE_REPRESENTATION_RELATIONSHIP());+""".replace('#PARENT','#16' if other_parent else '#11')+        if other_parent:s+="#16=SHAPE_REPRESENTATION('',(#4,#8),#99);"+    return Step(HEADER+s+TAIL)++class Instances(unittest.TestCase):+    def test_same_parent_occurrences_fail_without_mutation(self):+        s=fixture();before=s.text+        with self.assertRaisesRegex(SystemExit,'instanced'):s.flatten()+        self.assertEqual(before,s.text)+    def test_different_parent_occurrences_fail_without_mutation(self):+        s=fixture(other_parent=True);before=s.text+        with self.assertRaisesRegex(SystemExit,'instanced'):s.flatten()+        self.assertEqual(before,s.text)+    def test_single_occurrence_still_bakes_placement(self):+        s=fixture(two=False);s.flatten()+        self.assertEqual(s.coords(9),[-2.,1.,0.])+        before=s.text;s.flatten();self.assertEqual(s.coords(9),[-2.,1.,0.])++if __name__=='__main__':unittest.main()