← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.14"+version = "0.2.15" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.14"+version = "0.2.15" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.14"+version = "0.2.15" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.14"+version = "0.2.15" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+21−3
@@ -48,8 +48,8 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes.-6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.-4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and so is a via within the spec's hole-to-hole minimum (`holeToHole`, default 0.2 mm) of any via or hole, or within `clearance` of another via's copper, whatever its net (the ERROR names the clash), and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.@@ -86,6 +86,18 @@ draws an arc on the timeline, and every later step becomes a redo that must be d `finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never jump past it. +**Pattern boards (0.2.15, issue #84).** A passive copper-pattern board (a reflector disc, a carrier plate:+no parts, no BOM, no schematic, no electrical load) takes the `pattern` sequence: `intake --kind pattern`+(KiCad only), or `"boardKind": "pattern"` in requirements.json (a run still at `rules` switches over on its+next `step`). Steps: intake, rules, models, outline, silkscreen, 3d, finish, export. `start` still needs a+spec (`{}` is fine). finish skips the gate, land route, pours, measure and the analyses, keeps the fab+rules gate, the mechanical and silkscreen checks and the export re-read, requires the live full DRC (bind+the board on a test box), and prints each electrical step as not applicable with its reason. `step` and+finish refuse the lane for a board that needs routing: footprint pads on more than one net, on a net no+zone carries, tracks or vias on such a net, or a spec with loads, hot parts, signal checks or a converter.+Pads with no net or only on the single pour net (machine pins on a GND plate) are allowed; a sourcing+profile still applies to any footprint in the BOM.+ 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@@ -195,6 +207,12 @@ After the pours and the analyses: `adom-fields analyze --board <the current boar  **A switching converter needs `voltages`, `sources` and `switching` in the spec (issue #75).** A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence (`evidence add --kind calcs`) carries a switching frequency (`fsw`, `fsw_kHz`, `switching_frequency_Hz`, ...) or a converter `topology`. Fields models the cycle, the hot loop, emissions and the PDN only with the rails, the part feeding each output and the switching node with its frequency: `"voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}`. `start`, `evidence add --kind calcs` and `analyze` warn when the block is missing; `tour fields` (the step's proof) and `finish` refuse it, and also refuse a solve whose `fields.json` says it modelled no converter (`review.converter` null) or checked no rail's PDN (`review.pdn.rails` all skipped). The refusal prints the block filled from calcs.json (VIN, VOUT, fsw) and the board (switch, inductor, rails); `riseNs` 8 is adom-fields' default edge time, an estimate to replace from the regulator datasheet. Add the block, solve again, serve, `tour fields`. +**Nothing to solve (0.2.15, issue #84).** When the spec has no loads, no hot parts and no signal checks+(and the design is not a converter), `tour fields` records the fields step's proof as not applicable,+reason "no loads, hot parts or signal checks in the spec", the way thermal-vias records 0 vias for an empty+`spec.hot`. finish lists it as not applicable and refuses the record once the spec names any of them. Never+add a load only to get a solve.+ ## Time: sessions, not the wall clock  The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.@@ -219,7 +237,7 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and `  ## The spec -`docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.+`docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Vias of the pad's net already at the pad (any size, board vias or the footprint's own through-hole via pads) count toward `count`, as in `thermal-vias plan`, and no planned via comes within the hole-to-hole minimum or the via-to-via clearance of a via or hole already there. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.  ## Design gates (schematic, molecule, fab rules, 3D models, copper pads) 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/boardkind.rsadded+167
@@ -0,0 +1,167 @@+//! Steps that do not apply to a board (issue #84), recorded with their reason instead of faked.+//!+//! The fields step: a spec with no loads, no hot parts and no signal checks gives Adom Fields nothing+//! to solve ("the spec has no loads, hot parts or signal checks: nothing to solve"). `tour fields`+//! then records the step's proof (`tour-fields`) as not applicable, with that reason, the way+//! `thermal-vias` records 0 vias when spec.hot is empty. Never when the design is a switching+//! converter (converter.rs) or the spec names any load, hot part or signal check. finish lists it,+//! and refuses a stale record once the spec gains any of them.+//!+//! The pattern lane: a passive copper-pattern board (a reflector disc, a carrier plate): no parts, no+//! BOM, no schematic, no electrical load. `intake --kind pattern` (or requirements.json+//! `"boardKind": "pattern"`) selects the `pattern` sequence in flows/board.json: intake, rules,+//! models, outline, silkscreen, 3d, finish, export. finish keeps the native DRC (the live full DRC with+//! the fab's rules), the fab rules gate, the mechanical and silkscreen checks, the 3D model and the+//! export re-read, and reports every electrical step it does not run and why.+//!+//! The lane is refused (conservatively) for a board that needs routing: footprint pads on more than+//! one net, or on a net no zone (pour) carries, or tracks or vias on such a net. A spec with loads,+//! hot parts, signal checks or a converter is refused too. Footprints whose pads carry no net (a+//! mounting hole) or only the single pour net (machine pins on a GND plate) are allowed; a profile's+//! sourcing check still applies to any of them that go in the BOM.++use aiflow_board::Board;+use serde_json::Value;+use std::collections::BTreeSet;++pub const PATTERN: &str = "pattern";++/// The fields step's not-applicable reason.+pub const FIELDS_NA: &str = "no loads, hot parts or signal checks in the spec";++/// The electrical steps the pattern lane leaves out, each with its reason (finish reports them).+pub const PATTERN_NA: &[(&str, &str)] = &[+    ("design, sourcing, parts, project-page, library, schematic, simulation, components, libraries", "no parts, BOM or schematic: a passive copper pattern"),+    ("test-plan", "no circuit to probe"),+    ("placement", "no parts to place"),+    ("routing", "no net needs routing (pads, if any, only on a single pour net)"),+    ("thermal-vias", "no hot parts"),+    ("pours", "the copper is the pattern itself, not a planned pour"),+    ("current, thermal", "nothing carries current"),+    ("fields", FIELDS_NA),+    ("nets", "no nets to walk through"),+];++fn requirements(data: &Value) -> Value {+    data["intake"]["requirements"].as_str().or(data["requirements"].as_str())+        .and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++/// The run is a pattern board: `intake --kind pattern`, or requirements.json "boardKind": "pattern".+pub fn is_pattern(data: &Value) -> bool {+    data["kind"] == PATTERN || requirements(data)["boardKind"] == PATTERN+}++fn nonempty(v: &Value) -> bool {+    match v { Value::Object(o) => !o.is_empty(), Value::Array(a) => !a.is_empty(), Value::Null => false, Value::Bool(b) => *b, _ => true }+}++/// Why the fields step applies to this spec (empty: it does not). `calcs` is the run's calcs evidence.+pub fn fields_applies(spec: &Value, calcs: Option<&Value>) -> Vec<String> {+    let mut why = Vec::new();+    if nonempty(&spec["loads"]) { why.push("spec.loads names loads".to_string()); }+    if nonempty(&spec["hot"]) { why.push("spec.hot names hot parts".to_string()); }+    if nonempty(&spec["signals"]) { why.push("spec.signals names signal checks".to_string()); }+    if let Some(c) = crate::converter::detect(spec, calcs) { why.push(format!("the design is a switching converter ({c})")); }+    why+}++/// Why this board cannot take the pattern lane (None: it can).+pub fn pattern_refusal(b: &Board) -> Option<String> {+    let zone_nets: BTreeSet<String> = b.zones().into_iter().map(|z| z.net).filter(|n| !n.trim().is_empty()).collect();+    let mut pad_nets: BTreeSet<String> = BTreeSet::new();+    let mut refs: BTreeSet<String> = BTreeSet::new();+    for p in b.pads.iter().filter(|p| !p.net.trim().is_empty()) { pad_nets.insert(p.net.clone()); refs.insert(p.reference.clone()); }+    let copper_nets: BTreeSet<String> = b.tracks().into_iter().map(|t| t.net).chain(b.vias().into_iter().map(|v| v.net)).filter(|n| !n.trim().is_empty()).collect();+    let list = |s: &BTreeSet<String>| s.iter().take(8).cloned().collect::<Vec<_>>().join(", ");+    if pad_nets.len() > 1 {+        return Some(format!("footprint pads are on {} nets ({}): a board with nets to connect needs the electrical lane", pad_nets.len(), list(&pad_nets)));+    }+    if let Some(n) = pad_nets.iter().next() {+        if !zone_nets.contains(n) {+            return Some(format!("footprint pads ({}) are on net {n}, which no zone (pour) carries: it needs routing", list(&refs)));+        }+    }+    let loose: BTreeSet<String> = copper_nets.into_iter().filter(|n| !zone_nets.contains(n) || (!pad_nets.is_empty() && !pad_nets.contains(n))).collect();+    if !loose.is_empty() {+        return Some(format!("tracks or vias carry net(s) {} that no single pour net joins: a routed board needs the electrical lane", list(&loose)));+    }+    None+}++/// The spec cannot be a pattern board's: it names loads, hot parts, signal checks or a converter.+pub fn pattern_spec_refusal(spec: &Value, calcs: Option<&Value>) -> Option<String> {+    let why = fields_applies(spec, calcs);+    (!why.is_empty()).then(|| format!("the spec is an electrical board's: {}", why.join("; ")))+}++/// finish's lines for the steps that did not apply: the pattern lane's electrical steps, and a fields+/// step recorded as not applicable.+pub fn finish_lines(data: &Value) -> Vec<String> {+    let mut out = Vec::new();+    let pattern = data["sequence"].as_array().map(|s| !s.iter().any(|x| x == "routing")).unwrap_or(false) && is_pattern(data);+    if pattern { for (steps, why) in PATTERN_NA { out.push(format!("{steps}: not applicable ({why})")); } }+    let tf = &data["outcomes"]["tour-fields"];+    if tf["notApplicable"] == true && !pattern { out.push(format!("fields: not applicable ({})", tf["reason"].as_str().unwrap_or(FIELDS_NA))); }+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;++    const HEAD: &str = r#"(kicad_pcb (version 20260101) (net 0 "") (net 1 "GND") (net 2 "SIG") (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts"))"#;+    fn pin(r: &str, x: f64, net: &str) -> String {+        let n = match net { "GND" => "(net 1 \"GND\")", "SIG" => "(net 2 \"SIG\")", _ => "" };+        format!(r#" (footprint "MP" (layer "F.Cu") (at {x} 5) (property "Reference" "{r}") (pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 1) (layers "*.Cu") {n}))"#)+    }+    const GND_ZONE: &str = r#" (zone (net 1) (net_name "GND") (layer "F.Cu") (uuid "z1") (polygon (pts (xy 1 1) (xy 19 1) (xy 19 19) (xy 1 19))))"#;+    fn board(body: &str) -> Board { Board::from_text("t", format!("{HEAD}{body})")).unwrap() }++    #[test]+    fn fields_is_not_applicable_only_on_an_empty_spec() {+        assert!(fields_applies(&json!({}), None).is_empty());+        assert!(fields_applies(&json!({"loads": {}, "hot": {}, "signals": {}, "pours": [{"net": "GND"}]}), None).is_empty());+        assert_eq!(fields_applies(&json!({"loads": {"5V": {"amps": 1}}}), None).len(), 1);+        assert_eq!(fields_applies(&json!({"hot": {"U1": {"watts": 0.2}}}), None).len(), 1);+        assert_eq!(fields_applies(&json!({"signals": {"capacitance": [{"a": "X"}]}}), None).len(), 1);+        // a converter is never not applicable, whatever the spec leaves out+        let why = fields_applies(&json!({"switchNodes": ["SW"]}), None);+        assert!(why.iter().any(|w| w.contains("converter")), "{why:?}");+        assert!(!fields_applies(&json!({}), Some(&json!({"fsw_kHz": 500}))).is_empty());+    }++    #[test]+    fn pattern_lane_refuses_a_board_that_needs_routing() {+        // a bare pattern, and machine pins on a single GND plate: allowed+        assert_eq!(pattern_refusal(&board("")), None);+        assert_eq!(pattern_refusal(&board(&format!("{}{}{GND_ZONE}", pin("MP1", 3.0, "GND"), pin("MP2", 15.0, "GND")))), None);+        // a mounting hole without a net: allowed+        assert_eq!(pattern_refusal(&board(&pin("H1", 3.0, ""))), None);+        // two nets: refused+        let w = pattern_refusal(&board(&format!("{}{}{GND_ZONE}", pin("MP1", 3.0, "GND"), pin("J1", 15.0, "SIG")))).unwrap();+        assert!(w.contains("2 nets"), "{w}");+        // one net no zone carries: refused+        let w = pattern_refusal(&board(&format!("{}{}", pin("MP1", 3.0, "GND"), pin("MP2", 15.0, "GND")))).unwrap();+        assert!(w.contains("no zone (pour) carries"), "{w}");+        // a routed net beside the plate: refused+        let w = pattern_refusal(&board(&format!("{}{GND_ZONE} (segment (start 2 2) (end 8 2) (width 0.25) (layer \"B.Cu\") (net 2))", pin("MP1", 3.0, "GND")))).unwrap();+        assert!(w.contains("SIG"), "{w}");+        assert!(pattern_spec_refusal(&json!({"loads": {"GND": {"amps": 1}}}), None).is_some());+        assert!(pattern_spec_refusal(&json!({}), None).is_none());+    }++    #[test]+    fn finish_names_what_did_not_apply() {+        let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+        let seq = crate::sequence::sequence_of(&flow, PATTERN);+        let d = json!({"kind": "pattern", "sequence": seq});+        let lines = finish_lines(&d);+        assert!(lines.iter().any(|l| l.starts_with("routing: not applicable")), "{lines:?}");+        assert!(lines.iter().any(|l| l.starts_with("fields: not applicable (no loads, hot parts or signal checks in the spec)")), "{lines:?}");+        let d = json!({"sequence": ["routing", "fields"], "outcomes": {"tour-fields": {"notApplicable": true, "reason": FIELDS_NA}}});+        assert_eq!(finish_lines(&d), vec![format!("fields: not applicable ({FIELDS_NA})")]);+        assert!(finish_lines(&json!({"sequence": ["routing"]})).is_empty());+    }+}
crates/adom-aiflow/src/main.rs+108−33
@@ -43,6 +43,7 @@ mod decoupling_check; mod placement_intent; mod rules_card; mod demo;+mod boardkind; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -132,7 +133,8 @@ enum Cmd {     #[arg(long)] requirements: Option<String>, #[arg(long)] engine: Option<String>, /// the desktop for pre-board clips (a monitor recording while no editor is open)     #[arg(long)] target: Option<String>, /// the run's wiki page (owner/slug)     #[arg(long)] page: Option<String>, /// the EDA tool the board is built in (default: the brief's own words, else KiCad); fixes the run's step sequence-    #[arg(long, value_parser = ["kicad", "fusion", "altium"])] eda: Option<String> },+    #[arg(long, value_parser = ["kicad", "fusion", "altium"])] eda: Option<String>, /// `pattern`: a passive copper-pattern KiCad board (no parts, BOM, schematic or electrical load) takes the short pattern sequence; finish reports the electrical steps as not applicable. Refused later for a board with pads on nets that need routing.+    #[arg(long, value_parser = ["pattern"])] kind: Option<String> },     /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, sources that fit the profile (fab: Mouser or Adom stock, no LCSC/JLCPCB parts; jlcpcb: LCSC numbers), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile.     Sourcing { what: String, #[arg(long)] bom: String, #[arg(long, value_parser = ["fab", "jlcpcb"])] profile: Option<String>, /// boards to build (default: requirements build.quantity, else 10)     #[arg(long)] qty: Option<u64>, /// the board to diff against (default: the run's current board)@@ -389,6 +391,12 @@ fn guard_budgets(r: &Run, spec: &Value) { }  /// The spec when there is one, Null before the board (an intake run) or when it does not read.+/// finish's "not applicable" block (issue #84): each step the run did not do, and why.+fn na_text(v: &Value) -> String {+    let lines: Vec<&str> = v.as_array().into_iter().flatten().filter_map(Value::as_str).collect();+    if lines.is_empty() { String::new() } else { format!("\nnot applicable to this board:\n  {}", lines.join("\n  ")) }+}+ fn spec_soft(r: &Run) -> Value {     let spec = r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);     spec_nets::align(spec, &r.current_board())@@ -2526,7 +2534,7 @@ fn main() {         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()]); }+            if !result.status.success() { err(&silkproof::checker_failure(&String::from_utf8_lossy(&result.stdout), &String::from_utf8_lossy(&result.stderr)), &["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()]); }             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());@@ -2717,13 +2725,14 @@ fn main() {             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);             print_next(&cli);         }-        Cmd::Intake { prompt_time, brief, requirements, engine, target, page, eda } => {+        Cmd::Intake { prompt_time, brief, requirements, engine, target, page, eda, kind } => {             let t = thread(&cli);             if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }             if let Some(r) = Run::open(&dir) {                 err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);             }             for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }+            if kind.is_some() && eda.as_deref().is_some_and(|e| e != "kicad") { err("--kind pattern is a KiCad lane", &["Build the pattern board in KiCad (--eda kicad, or leave --eda out).".into()]); }             let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());             // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both             let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));@@ -2732,6 +2741,8 @@ fn main() {             // the EDA tool, named: the step sequence is fixed at the first step after intake, before any             // backend project exists, so a brief that never says "Altium" must not fall into kicad-new             if let Some(e) = eda { r.data["eda"] = json!(e); }+            // a passive copper-pattern board: the pattern sequence (boardkind.rs)+            if let Some(k) = kind { r.data["kind"] = json!(k); }             let idx = r.turn_begin(&command_line());             record_usage(&mut r, "start");             r.save().unwrap();@@ -2955,6 +2966,9 @@ fn main() {             let parts = r.data["outcomes"]["parts"].clone();             if parts["pass"] != true { err("the parts are not all on the wiki yet: run `parts` until it passes", &[]); }             if !page.contains('/') { err("--page is <owner>/<slug>", &[]); }+            let bom_path = parts["bom"].as_str().unwrap_or("").to_string();+            let stale = project_lib::stale(&parts, &bom_path, std::fs::read_to_string(&bom_path).ok().as_deref());+            if !stale.is_empty() { err(&format!("the parts record is stale: {}", stale.join("; ")), &[format!("Run `parts` again (`parts --bom <current BOM>{}`), then `project-page` again.", parts["projectLib"].as_str().map(|d| format!(" --project-lib {d}")).unwrap_or_default())]); }             let (spec, req) = (spec_soft(&r), requirements_of(&r));             let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::title(&r.data));             let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());@@ -2981,9 +2995,12 @@ fn main() {                 let url = signed_in_wiki_url(page).unwrap_or_else(|| v["url"].as_str().unwrap_or("").to_string());                 let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "Project page", "--url", &url, "--ai-thread", &t, "--reason", "adom-aiflow: the board's project page, revision 1"]).stdin(std::process::Stdio::null()).output();             }+            // --no-push builds everything locally: never claim the page was created or updated (issue #82)+            let page_said = if *no_push { format!("project page built locally in {} (--no-push: nothing pushed; {} was not created or updated)", dir.join("project-page").display(), v["url"].as_str().unwrap_or(page)) }+                else { format!("project page {} ({})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }) };             if lane == "altium" {                 let from_lib: Vec<&str> = v["projectLibParts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();-                ok(&format!("project page {} ({}): README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages{})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }),+                ok(&format!("{}: README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages{})", page_said, v["parts"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }),                    &[if from_lib.is_empty() { String::new() } else { format!("from the board's project library (no public page yet): {}", from_lib.join(", ")) },                      "Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages (project-library parts from their own KiCad files, through the same converters). Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]);                 return;@@ -2991,8 +3008,8 @@ fn main() {             let c = &v["counts"];             let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();             if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }-            ok(&format!("project page {} ({}): README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),-               &[hints, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());+            ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),+               &[hints, if *no_push { vec![format!("Local build only: the README, PCM zip and repository files are in {}. The repository URL above works only after `project-page` runs without --no-push.", dir.join("project-page").display())] } else { vec![] }, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());         }         Cmd::Pcm { what: _ } => {             thread(&cli);@@ -3273,7 +3290,7 @@ fn main() {                         if rep["errorCode"] == "board_mismatch" {                             err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);                         }-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);                     }                     ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);                 }@@ -3319,15 +3336,17 @@ fn main() {             std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();             let planp = dir.join("plan-route.json");             let bc = report["boardCopper"].clone();-            let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) > 0;+            // filled zone islands count: a pour alone can join a net's pads (issue #84)+            let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) + bc["zoneIslands"].as_u64().unwrap_or(0) > 0;+            for w in bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str()) { eprintln!("WARNING {w}"); }             let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});             if has_copper {                 let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);                 plan_doc["boardCopper"] = bc.clone();                 plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {-                    format!("nothing to route: the board's own copper ({} track pieces, {} vias, filled zones) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"])+                    format!("nothing to route: the board's own copper ({} track pieces, {} vias, {} filled zone islands) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"], bc["zoneIslands"])                 } else {-                    format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias); {complete} nets are complete on it and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["openConnections"])+                    format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias, {} filled zone islands); {complete} nets are complete on it ({}) and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["zoneIslands"], bc["netsComplete"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default(), bc["openConnections"])                 });             }             if !regions.is_empty() {@@ -3366,9 +3385,9 @@ fn main() {             }             decoupling_check::refuse_errors("route", &dec);             if has_copper && plan.is_empty() {-                ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);+                ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias, {} filled zone islands); empty plan {}", bc["trackPieces"], bc["vias"], bc["zoneIslands"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);             } else {-                let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };+                let copper_line = if has_copper { format!("; the board's own copper was kept (nets already complete on it, skipped: {}) and {} connection(s) it left open were planned", bc["netsComplete"].as_array().filter(|a| !a.is_empty()).map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_else(|| "none".into()), bc["openConnections"]) } else { String::new() };                 ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);             }         }@@ -3555,6 +3574,18 @@ fn main() {                     return;                 }             }+            if what == "pours" {+                // zones adopted with this exact plan that still match it: nothing to land, no bridge call (issue #83)+                if let Some(zp) = r.outcomes().get("pour").and_then(|p| p["pours"].as_str()).map(str::to_string) {+                    let b = board_of(&r);+                    if let Ok(n) = pourverify::adopted_unchanged(&r.data, Path::new(&zp), &b) {+                        r.log("land-pours-skipped", json!({"plan": zp, "zones": n, "why": "adopted zones already match the plan"}));+                        r.save().unwrap();+                        ok(&format!("adopted zones already match the plan; nothing to land ({n} zone(s) adopted by `verify-pours --adopt` with this plan, unchanged on the saved board)"), &[if pourverify::is_existing(&r.data["outcomes"]["land-pours"]) { "The adopted-existing pours proof stands. Next: adom-aiflow measure, analyze current, analyze thermal, finish.".to_string() } else { format!("The pours proof was replaced by a later landing attempt: run `verify-pours --plan {zp}` again to record it (no copper changes), then measure, analyze current, analyze thermal, finish.") }]);+                        return;+                    }+                }+            }             let br = bridge_of(&r, &cli);             let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();             match what.as_str() {@@ -3573,7 +3604,7 @@ fn main() {                     r.save().unwrap();                     if let Some(i) = stopped {                         let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);                     }                     ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);                 }@@ -3603,6 +3634,11 @@ fn main() {                         }                     }                     let mut copper_cache: Option<Vec<Copper>> = None;+                    // and keep the spec's hole-to-hole minimum and via-to-via clearance from every via and+                    // hole, live or planned, of any net (issue #83): checked for every trace before any lands+                    let spec_v = r.data["spec"].as_str().filter(|p| Path::new(p).is_file()).map(read_json).unwrap_or(Value::Null);+                    let (h2h, via_clearance) = (aiflow_pours::hole_to_hole(&spec_v), spec_v["clearance"].as_f64().unwrap_or(0.2));+                    let mut holes_cache: Option<Vec<landing::Hole>> = None;                     let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> {                         let t = landing::resolve(e, &live.pads)?;                         if t.vias.is_empty() { return None; }@@ -3612,7 +3648,15 @@ fn main() {                             for p in &plans { c.extend(landing::plan_copper(p, &live.pads)); }                             c                         });-                        t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)})))+                        if let Some(refusal) = t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)}))) { return Some(refusal); }+                        let holes = holes_cache.get_or_insert_with(|| {+                            let mut h = landing::live_holes(live);+                            h.extend(landing::board_holes(&b));+                            for p in &plans { h.extend(landing::plan_holes(p, &live.pads)); }+                            h+                        });+                        let drill = e["viaDrill"].as_f64().unwrap_or(t.via_size / 2.0);+                        t.vias.iter().find_map(|v| landing::via_hole_clash(&t.net, *v, t.via_size, drill, holes, h2h, via_clearance).map(|what| json!({"success": false, "errorCode": "via_hole_clash", "net": t.net, "at": [v.0, v.1], "clashes": what, "message": format!("the planned {} via at ({:.2}, {:.2}) is too close to {what}", t.net, v.0, v.1)})))                     };                     let stage = format!("land-{what}");                     r.stage_start(&stage, Some("binary"), None);@@ -3661,7 +3705,7 @@ fn main() {                     }                     if let Some(i) = stopped {                         let rep = res.replies.last().cloned().unwrap_or(Value::Null);-                        if rep["errorCode"] == "via_touches_other_net" {+                        if rep["errorCode"] == "via_touches_other_net" || rep["errorCode"] == "via_hole_clash" {                             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. 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)]);@@ -3706,15 +3750,11 @@ fn main() {                     if !Bridge::ok(&after) { results.push(("post-landing-readback".into(), after.clone())); }                     let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();                     r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);-                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));+                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"], "message": rep["message"], "reply": if Bridge::ok(rep) { Value::Null } else { json!(aiflow_run::clip_text(&rep.to_string(), 1200)) }})).collect::<Vec<_>>()}));                     write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));                     r.save().unwrap();                     if let Some((net, rep)) = failed.first() {-                        let mut types: BTreeMap<String, usize> = BTreeMap::new();-                        for v in rep["violations"].as_array().cloned().unwrap_or_default() {-                            *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;-                        }-                        err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);+                        err(&pourverify::refusal(net, rep, &zones), &[format!("Full replies: {}. A zone adopted with `verify-pours --adopt` and unchanged since is not landed again; this plan was sent because {}.", dir.join("land-pours-log.json").display(), pourverify::adopted_unchanged(&r.data, Path::new(&zp), &b).err().unwrap_or_default()),"starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);                     }                     ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);                 }@@ -3868,12 +3908,23 @@ fn main() {             }             let o = r.outcomes().clone();             let mut missing = Vec::new();+            // a pattern board's run (boardkind.rs): the electrical steps are not applicable, the native DRC is the live one+            let pattern = boardkind::is_pattern(&r.data) && r.data["sequence"].as_array().is_some_and(|s| !s.iter().any(|x| x == "routing"));+            let not_applicable = boardkind::finish_lines(&r.data);+            if pattern {+                if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref())) { missing.push(format!("pattern lane: {why} (start a run on the electrical lane)")); }+                if r.data["target"].is_null() { missing.push("native DRC: a pattern board's DRC is finish's live full DRC with the fab rules; bind the board on a test box (start --target <box> --remote-board <path>, bind-remote-board) and finish again".into()); }+            }+            if o.get("tour-fields").is_some_and(|f| f["notApplicable"] == true) {+                let why = boardkind::fields_applies(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref());+                if !why.is_empty() { missing.push(format!("fields: recorded as not applicable, but now {} (solve with adom-fields, then `tour fields`)", why.join("; "))); }+            }             let placement=o.get("place-land").cloned().unwrap_or(Value::Null);             if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {                 missing.push("place verify (saved board or spec changed; verify again)".into());             }             let gate = o.get("gate").cloned().unwrap_or(Value::Null);-            if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {+            if !pattern && (gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0)) {                 missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());             }             if let Some(m) = decoupling_check::blocks_finish(o.get("decoupling")) { missing.push(m); }@@ -3884,13 +3935,13 @@ fn main() {             if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {                 missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));             }-            if !o.contains_key("land-route") {+            if !pattern && !o.contains_key("land-route") {                 missing.push("land route".into());             }-            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {+            if !pattern && o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {                 missing.push("land pours or verify-pours (every planned zone accepted; current inputs required)".into());             }-            if !measurement_usable(o.get("measure")) {+            if !pattern && !measurement_usable(o.get("measure")) {                 missing.push("measure (successful native copper readback)".into());             }             // a Fusion run is judged on the board as it is in Fusion now@@ -3899,6 +3950,7 @@ fn main() {             let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };             let mut acked_peaks: Vec<Value> = Vec::new();             for k in ["analyze-current", "analyze-thermal"] {+                if pattern { continue; }                 if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {                     missing.push(format!("{k} (pass)"));                 } else {@@ -4003,7 +4055,9 @@ fn main() {             }             // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for             let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));-            if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)) {+            // a pattern board with nothing in its BOM has nothing to source; any BOM footprint keeps the check+            let nothing_to_source = pattern && preboard::board_refs(&board_now()).0.is_empty();+            if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)).filter(|_| !nothing_to_source) {                 let sc = o.get("sourcing").cloned().unwrap_or(Value::Null);                 if sc["pass"] != true { missing.push(format!("sourcing check on the {p} profile (sourcing check --bom <csv>)")); }                 else if sc["boardChecked"] != true { missing.push("sourcing check against the board's references (run it again now the board exists)".into()); }@@ -4079,9 +4133,11 @@ fn main() {             if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }             r.data["summary"]["projectLibraryParts"] = json!(lib_parts);             r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);+            r.data["summary"]["notApplicable"] = json!(not_applicable);+            if nothing_to_source { r.data["summary"]["notApplicable"].as_array_mut().unwrap().push(json!("sourcing check: not applicable (no footprint in the BOM)")); }             r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));             r.save().unwrap();-            ok(&(format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\npours: {}\n{}{}", r.data["engine"], r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n  {}", reviewed.join("\n  ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+            ok(&(format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\npours: {}\n{}{}", r.data["engine"], r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &na_text(&r.data["summary"]["notApplicable"]) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n  {}", reviewed.join("\n  ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);         }         Cmd::Capture { action, label, reason } => {             let mut r = load_run(&cli);@@ -4491,9 +4547,9 @@ fn main() {         Cmd::Timeline { action, port, json: presentation } => {             let mut r = load_run(&cli);             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {-                let want = json!(sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));-                if r.data["sequence"] != want { r.data["sequence"] = want; r.save().unwrap(); }+            {+                let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data));+                if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); r.save().unwrap(); }             }             let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));             let tl = sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report());@@ -4799,6 +4855,18 @@ fn main() {         Cmd::Tour { what, nets, port, on } => {             let t = thread(&cli);             let mut r = load_run(&cli);+            // nothing for Adom Fields to solve (issue #84): the fields step is recorded as not applicable, never faked+            if what == "fields" && !r.board_pending() {+                let spec_path = r.data["spec"].as_str().unwrap_or("").to_string();+                let calcs = converter::calcs_of_run(&r.data);+                if Path::new(&spec_path).is_file() && boardkind::fields_applies(&spec_soft(&r), calcs.as_ref()).is_empty() {+                    r.outcome("tour-fields", json!({"notApplicable": true, "reason": boardkind::FIELDS_NA, "spec": spec_path, "specSha256": sha256_file(&spec_path), "board": r.current_board()}));+                    r.log("step-not-applicable", json!({"step": "fields", "reason": boardkind::FIELDS_NA}));+                    r.save().unwrap();+                    ok(&format!("fields: not applicable ({}); recorded as the fields step's proof (tour-fields), nothing solved or filmed", boardkind::FIELDS_NA), &["finish lists the fields step as not applicable, and refuses this record if the spec later names a load, hot part or signal check (then solve with adom-fields and `tour fields` again). Never add a load only to get a solve.".into()]);+                    return;+                }+            }             // a localhost Fields URL ({{port}}.localhost, 127.0.0.1) is reachable only on the machine             // hosting this container: film it there (the user's primary machine), not on the test box             let local_proxy = what == "fields" && std::env::var("VSCODE_PROXY_URI").map(|u| preboard::proxy_is_local(&u)).unwrap_or(false);@@ -5136,9 +5204,16 @@ fn main() {             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();             // the lane can still change while only intake is done (the run learns it is a Fusion board from the             // brief or the requirements written during intake)-            if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {-                let want = json!(sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));-                if r.data["sequence"] != want { r.data["sequence"] = want; }+            {+                let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data));+                if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); }+            }+            // the pattern lane is for boards with nothing to connect (issue #84): refuse it for one that needs routing+            if boardkind::is_pattern(&r.data) && !r.board_pending() && sequence::sequence_key(&dir, &r.data) == boardkind::PATTERN {+                let calcs = converter::calcs_of_run(&r.data);+                if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), calcs.as_ref())) {+                    err(&format!("this run is a pattern board (`intake --kind pattern` or requirements boardKind), but {why}"), &["The pattern lane leaves out placement, routing, pours, current, thermal and fields. A board with nets to connect, loads or hot parts takes the electrical lane: start a new run without --kind pattern (and without requirements boardKind).".into()]);+                }             }             let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();             // --back is a return: it may only go to an earlier step of the flow, never jump ahead past the checks
crates/adom-aiflow/src/placement_verify.rs+15
@@ -154,8 +154,23 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after:     Ok(verified) } +/// Issue #84: a live placement refused because a footprint is locked (`footprint_locked`). A board whose+/// placement is fully fixed (locked by design, adopted as built) needs no landing: `place verify` is the+/// placement step's proof for it. Never unlock parts or pass allowLocked only to get a landing.+pub fn locked_hint(reply: &serde_json::Value) -> Option<String> {+    let locked = reply["errorCode"] == "footprint_locked" || reply.to_string().contains("footprint_locked");+    locked.then(|| format!("{} is locked. When the placement is fixed as it is (locked by design, or adopted as built), `place verify` is the placement step's proof: it checks the saved board's placement against the spec and the live board and records it, with no moves. Land moves only for parts that should move; never unlock a part just to get a landing.", reply["offendingRefs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).filter(|s| !s.is_empty()).unwrap_or_else(|| "a footprint".into())))+}+ #[cfg(test)] mod tests {+    #[test]+    fn a_locked_footprint_points_at_place_verify() {+        let h = super::locked_hint(&serde_json::json!({"errorCode": "footprint_locked", "offendingRefs": ["MP1"], "message": "pass allowLocked:true"})).unwrap();+        assert!(h.starts_with("MP1 is locked") && h.contains("`place verify`"), "{h}");+        assert!(super::locked_hint(&serde_json::json!({"errorCode": "courtyard_overlap"})).is_none());+    }+     use super::*;     use serde_json::json;     fn fixture() -> (Board,Value,Value) {
crates/adom-aiflow/src/pourverify.rs+80
@@ -538,6 +538,52 @@ pub fn adopt(data: &mut Value, proof: &Value, why: &str) -> Result<Vec<String>,     Ok(ids) } +/// Issue #83: `land pours` after `verify-pours --adopt`. When the latest adoption of this exact plan+/// (same sha256) owns every zone the plan matches, and the saved board's zones still match the plan+/// exactly, there is nothing to land: re-landing them would only ask the bridge to replace zones with+/// themselves. Ok(zone count) then; Err(why not) sends `land pours` on to the bridge as before.+pub fn adopted_unchanged(data: &Value, plan: &Path, board: &Board) -> Result<usize, String> {+    let sha = hash(plan)?;+    let adoption = data["zoneAdoptions"].as_array().into_iter().flatten().rev()+        .find(|a| a["planSha256"].as_str() == Some(sha.as_str()))+        .ok_or("no `verify-pours --adopt` of this plan (same sha256) is recorded")?;+    let plan_v: Value = serde_json::from_str(&std::fs::read_to_string(plan).map_err(|e| format!("{}: {e}", plan.display()))?).map_err(|e| format!("{}: {e}", plan.display()))?;+    let proof = check(board, &plan_v)?;+    if proof["pass"] != true {+        return Err(format!("the saved zones no longer match the adopted plan: {}", proof["failed"]));+    }+    let adopted: Vec<&str> = adoption["zones"].as_array().into_iter().flatten().filter_map(|z| z["uuid"].as_str()).collect();+    let owned: Vec<&str> = data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();+    let groups = proof["groups"].as_array().cloned().unwrap_or_default();+    for g in &groups {+        let id = g["boardZoneUuid"].as_str().unwrap_or("");+        if id.is_empty() || !adopted.contains(&id) || !owned.contains(&id) {+            return Err(format!("zone {} ({}) was not adopted with this plan", g["name"].as_str().unwrap_or("?"), if id.is_empty() { "no uuid" } else { id }));+        }+    }+    Ok(groups.len())+}++/// Issue #83: a `land pours` refusal with everything the bridge said (clipped) and what aiflow sent.+pub fn refusal(step: &str, reply: &Value, zones: &[Value]) -> String {+    let mut types: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();+    for v in reply["violations"].as_array().into_iter().flatten() {+        *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;+    }+    let code = reply["errorCode"].as_str().unwrap_or("no errorCode");+    let msg = reply["message"].as_str().or(reply["error"].as_str()).unwrap_or("");+    let sent: Vec<String> = zones.iter().map(|z| {+        let layers: Vec<&str> = z["layers"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();+        format!("{} ({}, {})", z["name"].as_str().unwrap_or("unnamed"), z["net"].as_str().unwrap_or(if z.get("keepout").is_some() { "keepout" } else { "no net" }), layers.join("+"))+    }).collect();+    let mut out = format!("{step} refused: {code}");+    if !msg.is_empty() { out.push_str(&format!(": {msg}")); }+    if !types.is_empty() { out.push_str(&format!("; violations {types:?}")); }+    out.push_str(&format!("\nbridge reply: {}", aiflow_run::clip_text(&reply.to_string(), 1200)));+    out.push_str(&format!("\nsent {} zone(s): {}", zones.len(), sent.join("; ")));+    out+}+ type Reply = Result<(String, Vec<String>), (String, Vec<String>)>;  /// `verify-pours`: compare, bind the plan's provenance, and optionally record a reviewed plan or@@ -913,4 +959,38 @@ mod tests {         let _ = std::fs::remove_dir_all(d);         let _ = std::fs::remove_dir_all(d2);     }+    // -- issue #83: land pours after adoption, and refusal detail ----------------------------------+    #[test]+    fn adopted_zones_that_still_match_need_no_landing() {+        let d = tmpdir("adopted");+        let plan = write_plan(&d, "plan.json");+        let pv: Value = serde_json::from_str(&std::fs::read_to_string(&plan).unwrap()).unwrap();+        let mut proof = check(&offline(), &pv).unwrap();+        proof["planSha256"] = json!(hash(&plan).unwrap());+        let mut data = json!({});+        assert!(adopted_unchanged(&data, &plan, &offline()).unwrap_err().contains("no `verify-pours --adopt`"));+        adopt(&mut data, &proof, "written offline").unwrap();+        assert_eq!(adopted_unchanged(&data, &plan, &offline()).unwrap(), 1);+        // a zone edited after the adoption: land as before+        let moved = Board::from_text("o", format!("{PREFIX} {})", OFFLINE.replace("(xy 9 9) (xy 1 9)", "(xy 9 8) (xy 1 9)"))).unwrap();+        assert!(adopted_unchanged(&data, &plan, &moved).unwrap_err().contains("no longer match"));+        // a different plan (another sha256) was not adopted+        let other = d.join("other.json");+        std::fs::write(&other, format!("{} ", pv)).unwrap();+        assert!(adopted_unchanged(&data, &other, &offline()).is_err());+        // ownership given up since: not adopted any more+        data["ownedZoneIds"] = json!([]);+        assert!(adopted_unchanged(&data, &plan, &offline()).unwrap_err().contains("not adopted"));+        let _ = std::fs::remove_dir_all(d);+    }+    #[test]+    fn a_refusal_names_the_reply_and_what_was_sent() {+        let zones = vec![json!({"name":"GND top","net":"GND","layers":["F.Cu"]}), json!({"name":"GND bottom","net":"GND","layers":["B.Cu","In1.Cu"]})];+        let m = refusal("native-zone-plan", &json!({"success":false,"errorCode":"zone_plan_refused","details":{}}), &zones);+        assert!(m.starts_with("native-zone-plan refused: zone_plan_refused"), "{m}");+        assert!(m.contains("bridge reply: {") && m.contains("\"details\":{}"), "{m}");+        assert!(m.contains("sent 2 zone(s): GND top (GND, F.Cu); GND bottom (GND, B.Cu+In1.Cu)"), "{m}");+        let m = refusal("native-zone-plan", &json!({"errorCode":"drc","message":"clearance at (1, 2)","violations":[{"type":"clearance"},{"type":"clearance"}]}), &zones);+        assert!(m.contains("drc: clearance at (1, 2); violations {\"clearance\": 2}"), "{m}");+    } }
crates/adom-aiflow/src/project_lib.rs+41
@@ -159,6 +159,29 @@ pub fn recheck(parts: &Value) -> Vec<String> {     gaps } +/// Issue #83: the `parts` record against the current BOM and project library, before `project-page`+/// packages it. `bom` is the recorded BOM file's text now (None when it can no longer be read). Each entry+/// names a stale part: in the record but not in the BOM, in the BOM but not in the record, or a+/// project-library part whose symbol, footprint or model is gone.+pub fn stale(parts: &Value, bom_path: &str, bom: Option<&str>) -> Vec<String> {+    let mut out = Vec::new();+    let recorded: Vec<String> = parts["parts"].as_array().into_iter().flatten().filter_map(|p| p["mpn"].as_str().map(str::to_string)).collect();+    match bom {+        None => out.push(format!("the BOM the parts record was made from ({bom_path}) can no longer be read")),+        Some(text) => {+            let now: Vec<String> = crate::preboard::bom_parts(text).iter().filter_map(|r| r["mpn"].as_str().map(key)).collect();+            for m in &recorded { if !now.contains(&key(m)) { out.push(format!("part {m} is in the parts record but not in the BOM {bom_path}")); } }+            let rec: Vec<String> = recorded.iter().map(|m| key(m)).collect();+            let mut seen = Vec::new();+            for m in crate::preboard::bom_parts(text).iter().filter_map(|r| r["mpn"].as_str()) {+                if !rec.contains(&key(m)) && !seen.contains(&key(m)) { seen.push(key(m)); out.push(format!("part {m} is in the BOM {bom_path} but not in the parts record")); }+            }+        }+    }+    for g in recheck(&parts["parts"]) { out.push(format!("project library part {g}")); }+    out+}+ #[cfg(test)] mod tests {     use super::*;@@ -219,4 +242,22 @@ mod tests {         assert!(ProjectLib::load(&d).err().unwrap().contains("provenance.json"));         let _ = std::fs::remove_dir_all(&d);     }+    #[test]+    fn a_stale_parts_record_is_named() {+        let d = tmp("stale");+        fixture(&d, true);+        let lib = ProjectLib::load(&d).unwrap();+        let mut rows = vec![json!({"mpn": "ABC-123", "refs": ["U1"], "wiki": null}), json!({"mpn": "R-1", "refs": ["R1"], "wiki": "adom/r-1"})];+        assert!(resolve(&mut rows, &lib).is_empty());+        let rec = json!({"parts": rows});+        let bom = "Ref,MPN\nU1,ABC-123\nR1,R-1\n";+        assert!(stale(&rec, "bom.csv", Some(bom)).is_empty());+        // the BOM dropped ABC-123 and its symbol left the library (the #83 report): both named+        assert_eq!(stale(&rec, "bom.csv", Some("Ref,MPN\nR1,R-1\nC1,C-9\n")), vec!["part ABC-123 is in the parts record but not in the BOM bom.csv".to_string(), "part C-9 is in the BOM bom.csv but not in the parts record".to_string()]);+        std::fs::write(d.join("lib.kicad_sym"), "(kicad_symbol_lib (version 20241209) (generator \"x\"))\n").unwrap();+        let why = stale(&rec, "bom.csv", Some(bom)).join("; ");+        assert!(why.contains("ABC-123") && why.contains("no symbol ABC-123"), "{why}");+        assert!(stale(&rec, "gone.csv", None)[0].contains("can no longer be read"));+        let _ = std::fs::remove_dir_all(&d);+    } }
crates/adom-aiflow/src/sequence.rs+36
@@ -75,12 +75,25 @@ pub fn title(data: &Value) -> String { /// Which sequence a run follows: its lane, except a KiCad run that starts with no board (a board /// from scratch: parts, project page, PCM library and schematic come first) follows `kicad-new`. /// `scratch: false` in run.json keeps the plain KiCad lane.+/// A KiCad pattern board (`intake --kind pattern`, requirements "boardKind": "pattern") follows `pattern`+/// (boardkind.rs): the electrical steps do not apply. pub fn sequence_key(dir: &Path, data: &Value) -> String {     let lane = lane(dir, data);+    if lane == "kicad" && crate::boardkind::is_pattern(data) { return crate::boardkind::PATTERN.into(); }     let no_board = data["boards"].as_array().map_or(true, |b| b.is_empty()) && data["remoteBoard"].as_str().map_or(true, |b| b.is_empty());     if lane == "kicad" && no_board && data["scratch"] != false { "kicad-new".into() } else { lane.into() } } +/// Whether the run's recorded sequence may still be replaced by `want`: while only intake was visited+/// (the lane is still being learned), or when the run became a pattern board and every step visited so+/// far is in the pattern sequence (a run stuck at `rules` on a parts-free board, issue #84).+pub fn may_resequence(data: &Value, want: &[String]) -> bool {+    let visited: Vec<&String> = data["steps"].as_object().map(|o| o.keys().collect()).unwrap_or_default();+    if visited.iter().all(|k| *k == "intake") { return true; }+    crate::boardkind::is_pattern(data) && data["sequence"].as_array().map_or(true, |s| s.iter().any(|x| x == "routing"))+        && visited.iter().all(|k| want.iter().any(|w| w == *k))+}+ pub fn sequence_of(flow: &Value, lane: &str) -> Vec<String> {     flow["sequence"][lane].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() }@@ -380,6 +393,29 @@ mod sequence_key_tests {         assert!(pos("parts") < pos("project-page") && pos("project-page") < pos("library") && pos("library") < pos("schematic") && pos("schematic") < pos("placement"));     } +    #[test]+    fn a_pattern_board_follows_the_pattern_sequence() {+        let d = std::env::temp_dir();+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "kicad", "kind": "pattern"})), "pattern");+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "fusion", "kind": "pattern"})), "fusion");+        let req = d.join(format!("aiflow-pattern-req-{}.json", std::process::id()));+        std::fs::write(&req, r#"{"boardKind": "pattern"}"#).unwrap();+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "kicad", "requirements": req, "boards": ["/x.kicad_pcb"]})), "pattern");+        let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+        let s = sequence_of(&flow, "pattern");+        assert_eq!(s, ["intake", "rules", "models", "outline", "silkscreen", "3d", "finish", "export"]);+        for step in &s { if let Some(p) = flow["proofs"][step.as_str()].as_array() { assert!(!p.is_empty()); } }+        // a run stuck at rules on the electrical sequence may switch; one that has routed may not+        let kicad_new = serde_json::json!(sequence_of(&flow, "kicad-new"));+        let stuck = serde_json::json!({"kind": "pattern", "sequence": kicad_new, "steps": {"intake": {}, "rules": {}}});+        assert!(may_resequence(&stuck, &s));+        let routed = serde_json::json!({"kind": "pattern", "sequence": kicad_new, "steps": {"intake": {}, "rules": {}, "routing": {}}});+        assert!(!may_resequence(&routed, &s));+        let plain = serde_json::json!({"sequence": kicad_new, "steps": {"intake": {}, "rules": {}}});+        assert!(!may_resequence(&plain, &s));+        let _ = std::fs::remove_file(req);+    }+     #[test]     fn an_altium_run_named_at_intake_puts_its_parts_and_project_page_before_its_library() {         let d = std::env::temp_dir();
crates/adom-aiflow/src/silkproof.rs+21
@@ -191,8 +191,29 @@ pub fn mark_gaps(input: &Value, board: &Board) -> Vec<String> {     vec![format!("{} label (mark) box(es) are smaller than the board's own silkscreen text ({}{}): label boxes must be the rendered text extents measured from the board (size, length, rotation and stroke)", short.len(), short.iter().take(8).cloned().collect::<Vec<_>>().join("; "), if short.len() > 8 { "; ..." } else { "" })] } +/// What a failed `silkscreen-obstacle-check.py` run says (issue #82): its own verdict line when it+/// finished the audit, otherwise the checker's exception (the last line of its traceback), so a bad+/// input never reports a bare `ERROR:`.+pub fn checker_failure(stdout: &str, stderr: &str) -> String {+    let out = stdout.trim();+    let err_line = stderr.lines().rev().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");+    let msg = if !out.is_empty() && err_line.is_empty() { out.to_string() }+        else if out.is_empty() && err_line.is_empty() { "the silkscreen checker failed without output".to_string() }+        else if out.is_empty() { format!("the silkscreen checker could not read the audit input: {err_line}") }+        else { format!("{out}; checker error: {err_line}") };+    aiflow_run::clip_text(msg.trim_start_matches("ERROR:").trim(), 600)+}+ #[cfg(test)] mod tests {+    #[test]+    fn checker_failure_shows_the_exception() {+        let tb = "Traceback (most recent call last):\n  File \"<string>\", line 19\nKeyError: 'box'\n";+        assert_eq!(super::checker_failure("", tb), "the silkscreen checker could not read the audit input: KeyError: 'box'");+        assert_eq!(super::checker_failure("ERROR: 0 coverage errors; 0 missing obstacles; 1 collisions\n", ""), "0 coverage errors; 0 missing obstacles; 1 collisions");+        assert!(!super::checker_failure("", "").is_empty());+    }+     use super::*;     const BOARD: &str = r#"(kicad_pcb (version 20240108)   (layers (0 "F.Cu" signal) (31 "B.Cu" signal))
crates/aiflow-bridge/src/landing.rs+70−2
@@ -16,7 +16,7 @@ pub const SAME_POINT_MM: f64 = 0.05; pub struct LiveSeg { pub net: String, pub a: Pt, pub b: Pt, pub width: f64, pub uuid: String }  #[derive(Clone, Debug)]-pub struct LiveVia { pub net: String, pub at: Pt, pub size: f64, pub uuid: String }+pub struct LiveVia { pub net: String, pub at: Pt, pub size: f64, pub drill: f64, pub uuid: String }  /// The copper a kicad_routing_state reply describes, plus the pads by name (J5.1) with net and centre. #[derive(Clone, Debug, Default)]@@ -39,7 +39,9 @@ impl LiveCopper {         }         for v in state["vias"].as_array().into_iter().flatten() {             let Some(at) = xy(&v["position"]) else { continue };-            live.vias.push(LiveVia { net: v["net_name"].as_str().unwrap_or("").to_string(), at, size: v["size"].as_f64().unwrap_or(0.0), uuid: v["uuid"].as_str().unwrap_or("").to_string() });+            let size = v["size"].as_f64().unwrap_or(0.0);+            // a reply without the drill: half the copper, the usual via ring+            live.vias.push(LiveVia { net: v["net_name"].as_str().unwrap_or("").to_string(), at, size, drill: v["drill"].as_f64().unwrap_or(size / 2.0), uuid: v["uuid"].as_str().unwrap_or("").to_string() });         }         for p in state["pads"].as_array().into_iter().flatten() {             let (Some(name), Some(at)) = (p["name"].as_str(), xy(p)) else { continue };@@ -154,6 +156,56 @@ pub fn via_touch(net: &str, at: Pt, r: f64, copper: &[Copper]) -> Option<String>     copper.iter().find(|c| c.net() != net && c.touches(at, r)).map(|c| c.what().to_string()) } +/// A drilled hole a planned via must keep the hole-to-hole minimum from, and (a via) the copper+/// clearance too, whatever its net: a live or planned via, a through-hole pad, a bare drill.+#[derive(Clone, Debug)]+pub struct Hole { pub at: Pt, pub size: f64, pub drill: f64, pub net: String, pub via: bool, pub what: String }++/// The live board's vias as holes.+pub fn live_holes(live: &LiveCopper) -> Vec<Hole> {+    live.vias.iter().map(|v| Hole { at: v.at, size: v.size, drill: v.drill, net: v.net.clone(), via: true, what: format!("live {} via at ({:.2}, {:.2})", v.net, v.at.0, v.at.1) }).collect()+}++/// A board's through-hole pads and bare drills as holes (vias come from the live board instead).+pub fn board_holes(board: &aiflow_board::Board) -> Vec<Hole> {+    let mut out: Vec<Hole> = board.pads.iter().filter(|p| p.thru).filter_map(|p| Some(Hole { at: (p.x, p.y), size: 2.0 * p.half_long, drill: p.drill?, net: p.net.clone(), via: false, what: format!("pad {} hole", p.key) })).collect();+    out.extend(board.holes.iter().map(|h| Hole { at: (h.x, h.y), size: h.diameter(), drill: h.diameter(), net: String::new(), via: false, what: format!("drilled hole {}{}", h.reference, if h.name.is_empty() { String::new() } else { format!(".{}", h.name) }) }));+    out+}++/// A routing plan's vias as holes, pads resolved on the live board.+pub fn plan_holes(plan: &Value, pads: &BTreeMap<String, (String, Pt)>) -> Vec<Hole> {+    let entries = plan["nets"].as_array().cloned().unwrap_or_default();+    let mut out = Vec::new();+    for e in expand_traces(&entries) {+        let Some(t) = resolve(&e, pads) else { continue };+        let drill = e["viaDrill"].as_f64().unwrap_or(t.via_size / 2.0);+        out.extend(t.vias.iter().map(|v| Hole { at: *v, size: t.via_size, drill, net: t.net.clone(), via: true, what: format!("planned {} via at ({:.2}, {:.2})", t.net, v.0, v.1) }));+    }+    out+}++/// The first hole a planned via of `net` at `at` (copper `size`, `drill`) clashes with: drill edge+/// to drill edge under `h2h`, or, for another via of any net, copper edge to copper edge under+/// `clearance` (a same-net via on top of another is still a drill clash; issue #83). The same via+/// (same net, same point) is not a clash with itself.+pub fn via_hole_clash(net: &str, at: Pt, size: f64, drill: f64, holes: &[Hole], h2h: f64, clearance: f64) -> Option<String> {+    let eps = 1e-4;+    holes.iter().find_map(|h| {+        let d = (h.at.0 - at.0).hypot(h.at.1 - at.1);+        if h.net == net && same(h.at, at) { return None; }+        let drills = (drill + h.drill) / 2.0 + h2h;+        if d + eps < drills {+            return Some(format!("{}: centres {d:.2} mm apart, under the {:.2} mm hole-to-hole minimum ({h2h} mm drill edge to drill edge)", h.what, drills));+        }+        let copper = (size + h.size) / 2.0 + clearance;+        if h.via && d + eps < copper {+            return Some(format!("{}: centres {d:.2} mm apart, under the {:.2} mm via-to-via spacing ({clearance} mm copper clearance)", h.what, copper));+        }+        None+    })+}+ /// A landed via that came back on a net other than the planned one. #[derive(Clone, Debug, PartialEq)] pub struct ViaMismatch { pub trace: usize, pub planned: String, pub actual: String, pub at: Pt, pub uuid: String }@@ -365,6 +417,22 @@ mod tests {         assert_eq!(via_touch("GND", (112.0, 80.4), 0.3, &plan_copper(&plan, &live.pads)).as_deref(), Some("planned /SW track"));     } +    #[test]+    fn a_planned_via_over_an_existing_same_net_via_is_refused() {+        // issue #83: an in-pad 0.5/0.25 GND via on the live board, and a planned 0.8/0.4 GND via 0.6 mm away+        let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 112.4, "y": 82.0}, "net_name": "GND", "size": 0.5, "drill": 0.25, "uuid": "ep1"})]));+        let holes = live_holes(&live);+        let clash = via_hole_clash("GND", (111.8, 82.0), 0.8, 0.4, &holes, 0.25, 0.2).expect("refused");+        assert!(clash.contains("live GND via at (112.40, 82.00)") && clash.contains("0.60 mm apart"), "{clash}");+        // with a hole-to-hole minimum the drills pass, the copper spacing still refuses it+        assert!(via_hole_clash("GND", (111.8, 82.0), 0.8, 0.4, &holes, 0.2, 0.2).unwrap().contains("via-to-via"));+        assert!(via_hole_clash("GND", (111.4, 82.0), 0.8, 0.4, &holes, 0.25, 0.2).is_none(), "1.0 mm away is clear");+        assert!(via_hole_clash("GND", (112.4, 82.0), 0.5, 0.25, &holes, 0.25, 0.2).is_none(), "the same via is not a clash");+        // a planned via of another plan counts the same way+        let plan = json!({"nets": [via_entry("GND", 120.0, 80.0)]});+        assert!(via_hole_clash("GND", (120.5, 80.0), 0.6, 0.3, &plan_holes(&plan, &live.pads), 0.25, 0.2).is_some());+    }+     #[test]     fn a_landed_via_on_another_net_is_named_with_both_nets() {         let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 113.3, "y": 92.55}, "net_name": "/SW", "size": 0.6, "uuid": "vsw"})]));
crates/aiflow-pours/src/lib.rs+76−6
@@ -123,6 +123,26 @@ pub fn hole_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hol     }) } +/// `hole_clear`, and also copper to copper at least `clearance` from every via and hole whatever its+/// net: two vias of one net may not overlap or crowd each other either (a 0.8 mm via 0.6 mm from a+/// 0.5 mm in-pad via is a drill and copper clash KiCad's DRC reports; issue #83).+pub fn via_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hole], h2h: f64, clearance: f64) -> bool {+    hole_clear(x, y, size, drill, net, holes, h2h, clearance)+        && holes.iter().all(|(hx, hy, hs, _, _)| (hx - x).hypot(hy - y) + 1e-6 >= (size + hs) / 2.0 + clearance)+}++/// The vias already at a pad: every via of `net` (any size), and every through-hole pad of `net` in+/// the same footprint (a footprint's own in-pad via pads), whose centre lies within the pad's copper+/// box grown by `grow` mm; the pad itself left out.+pub fn vias_at_pad(board: &Board, pad: &aiflow_board::Pad, net: &str, grow: f64) -> usize {+    let pts: Vec<Pt> = pad.polys.iter().flatten().copied().collect();+    let (x0, y0, x1, y1) = if pts.is_empty() { (pad.x - pad.half_long, pad.y - pad.half_long, pad.x + pad.half_long, pad.y + pad.half_long) } else { geom::bbox(&pts) };+    let within = |x: f64, y: f64| x >= x0 - grow && x <= x1 + grow && y >= y0 - grow && y <= y1 + grow;+    let vias = board.vias().into_iter().filter(|v| v.net == net && within(v.x, v.y)).count();+    let thru = board.pads.iter().filter(|q| q.thru && q.drill.is_some() && q.net == net && q.reference == pad.reference && !std::ptr::eq(*q, pad) && within(q.x, q.y)).count();+    vias + thru+}+ /// The pad connection and island settings a pour may carry, so a reviewed plan can describe a zone /// exactly: `connection` thermal (default), solid (or full) or none; `thermalGap` and /// `thermalBridge` in mm; `islandRemoval` always (default), never or area, with `islandAreaMin` in mm2.@@ -215,11 +235,19 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[     for tv in spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {         let Some(key) = tv.get("pad").and_then(|p| p.as_str()) else { continue };         let Some(p) = board.pad_by_key(key) else { continue };+        // a footprint can repeat a pad name (an exposed pad and its own in-pad via pads all "29"):+        // the heat pad is the largest copper of that name+        let p = board.pads.iter().filter(|q| q.reference == p.reference && q.name == p.name).max_by(|a, b| (a.half_long * a.half_short).partial_cmp(&(b.half_long * b.half_short)).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or(p);         let net = tv.get("net").and_then(|n| n.as_str()).unwrap_or(&p.net).to_string();-        let n = tv.get("count").and_then(|c| c.as_u64()).unwrap_or(2) as usize;+        let want = tv.get("count").and_then(|c| c.as_u64()).unwrap_or(2) as usize;         let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));-        let mut placed = 0;         let fits_inside = p.half_long - 0.4 > 0.0 && p.half_short - 0.4 > 0.0 && size <= 2.0 * p.half_short;+        // the pad's vias already on the board (any size) count toward `count`, as `thermal-vias plan`+        // counts them: in the pad for a pad that takes vias, on the ring just outside it for a small pin+        let existing = vias_at_pad(board, p, &net, if fits_inside { 0.0 } else { 0.25 + 0.6 + 3.0 * 0.35 });+        let n = want.saturating_sub(existing);+        if existing > 0 { eprintln!("thermal vias at {key}: {existing} via(s) of {net} already at the pad count toward {want}; planning {n} more"); }+        let mut placed = 0;         if fits_inside {             for (dx, dy) in [(-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)] {                 if placed >= n {@@ -230,7 +258,7 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[                     continue;                 }                 let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);-                if clear(x, y, &net, &blockers) && hole_clear(x, y, size, drill, &net, &holes, h2h, clearance) {+                if clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, clearance) {                     vias.push(via_entry(&net, x, y, size, drill));                     holes.push((x, y, size, drill, net.clone()));                     placed += 1;@@ -278,7 +306,7 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[                     (x - nx).hypot(y - ny) < vr + 0.1                 });                 let near_mine = mine.iter().any(|m| (m.0 - x).hypot(m.1 - y) < size + 0.3);-                if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && hole_clear(x, y, size, drill, &net, &holes, h2h, clearance) {+                if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, clearance) {                     vias.push(via_entry(&net, x, y, size, drill));                     mine.push((x, y)); holes.push((x, y, size, drill, net.clone()));                     placed += 1;@@ -286,14 +314,14 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[             }         }         if placed < n {-            eprintln!("thermal vias at {key}: placed {placed} of {n}; no more room clear of other nets (add stitchVias by hand, or accept fewer)");+            eprintln!("thermal vias at {key}: {} of {want} ({existing} already there, {placed} planned); no more room clear of other nets and of the vias and holes already there (add stitchVias by hand, or accept fewer)", existing + placed);         }     }     for sv in spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {         let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };         let (size, drill) = (sv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));         // a stitch via on top of a heatsink via (or any hole) is a drill hit: left out, said why-        if !hole_clear(x, y, size, drill, net, &holes, h2h, clearance) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the clearance of a via or hole already there or planned"); continue; }+        if !via_clear(x, y, size, drill, net, &holes, h2h, clearance) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the clearance of a via or hole already there or planned"); continue; }         if clear(x, y, net, &blockers) {             vias.push(via_entry(net, x, y, size, drill));             holes.push((x, y, size, drill, net.to_string()));@@ -557,6 +585,48 @@ mod hole_tests {             assert!(build(&b, &spec, None).is_err(), "{bad}");         }     }+    /// A QFN with a 3.4 mm exposed pad U1.29 at (113, 82) on GND carrying 10 in-pad 0.5/0.25 vias+    /// (2 x 5, x 112.4 and 113.6), as board vias or as the footprint's own through-hole "29" pads.+    fn qfn(as_pads: bool) -> Board {+        let ys = [80.8, 81.4, 82.0, 82.6, 83.2];+        let mut fp_vias = String::new();+        let mut board_vias = String::new();+        for x in [112.4, 113.6] {+            for y in ys {+                if as_pads { fp_vias.push_str(&format!(" (pad \"29\" thru_hole circle (at {} {}) (size 0.5 0.5) (drill 0.25) (layers \"*.Cu\") (net 1 \"GND\"))", ((x - 113.0) * 10.0f64).round() / 10.0, ((y - 82.0) * 10.0f64).round() / 10.0)); }+                else { board_vias.push_str(&format!(" (via (at {x} {y}) (size 0.5) (drill 0.25) (layers \"F.Cu\" \"B.Cu\") (net 1))")); }+            }+        }+        Board::from_text("fixture", format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 100 70)(end 126 94)(layer \"Edge.Cuts\")) (footprint \"QFN\" (layer \"F.Cu\") (at 113 82) (property \"Reference\" \"U1\") (pad \"29\" smd rect (at 0 0) (size 3.4 3.4) (layers \"F.Cu\") (net 1 \"GND\")){fp_vias}){board_vias})")).unwrap()+    }++    #[test]+    fn thermal_vias_count_and_clear_a_pads_existing_in_pad_vias() {+        // issue #83: count 12 planned three 0.8/0.4 vias 0.6 mm from the existing ones, count 10 the same+        for as_pads in [false, true] {+            let b = qfn(as_pads);+            let p = b.pads.iter().find(|p| p.key == "U1.29" && !p.thru).unwrap();+            assert_eq!(vias_at_pad(&b, p, "GND", 0.0), 10, "as pads: {as_pads}");+            let holes = board_holes(&b);+            for count in [10, 12] {+                let spec = json!({"thermalVias": [{"pad": "U1.29", "net": "GND", "count": count}]});+                let (_, vias, _) = build(&b, &spec, None).unwrap();+                if count == 10 { assert!(vias.is_empty(), "the 10 there already meet count 10: {vias:?}"); }+                for v in &vias {+                    let (x, y) = (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap());+                    for (hx, hy, hs, _, _) in &holes {+                        assert!((hx - x).hypot(hy - y) >= (0.8 + hs) / 2.0 + 0.2 - 1e-6, "planned via at ({x}, {y}) crowds the existing one at ({hx}, {hy})");+                    }+                }+            }+        }+        // the same pad bare: the planner still places its vias+        let bare = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 100 70)(end 126 94)(layer \"Edge.Cuts\")) (footprint \"QFN\" (layer \"F.Cu\") (at 113 82) (property \"Reference\" \"U1\") (pad \"29\" smd rect (at 0 0) (size 3.4 3.4) (layers \"F.Cu\") (net 1 \"GND\"))))".into()).unwrap();+        let (_, vias, _) = build(&bare, &json!({"thermalVias": [{"pad": "U1.29", "net": "GND", "count": 3}]}), None).unwrap();+        assert_eq!(vias.len(), 3);+        assert!(!via_clear(111.8, 82.0, 0.8, 0.4, "GND", &[(112.4, 82.0, 0.5, 0.25, "GND".into())], 0.2, 0.2), "same net still keeps via-to-via spacing");+    }+     #[test]     fn mask_only_npth_is_a_board_hole_for_stitch_vias() {         let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"T:USBC\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"J9\") (pad \"\" np_thru_hole circle (at 0 0) (size 0.65 0.65) (drill 0.65) (layers \"*.Mask\"))))".into()).unwrap();
crates/aiflow-router/src/board_copper.rs+83−1
@@ -44,7 +44,9 @@ impl Router {         let tracks: Vec<Track> = self.board.tracks().into_iter().filter(|t| t.width > 0.0).collect();         let vias: Vec<Via> = self.board.vias();         let zones = self.board.zones();-        if tracks.is_empty() && vias.is_empty() {+        // a board whose only copper is filled zones (a chassis pour joining through-hole pins, no+        // tracks or vias) still has connections made: read its islands too (issue #84)+        if tracks.is_empty() && vias.is_empty() && zones.iter().all(|z| z.fills.is_empty()) {             return;         }         let g = self.grid.clone();@@ -169,6 +171,19 @@ impl Router {         if self.nets[net].plane.is_some() { cc.via_or_zone } else { cc.copper }     } +    /// A plane net's cluster that its plane reaches with no stub and no via: a through-hole pad+    /// (on every layer), or a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane sits in+    /// that pour; issue #84). An inner-layer plane reaches only through-hole pads.+    pub fn on_plane(&self, net: usize, c: usize) -> bool {+        let p = &self.board.pads[self.nets[net].clusters[c].primary];+        if p.thru { return true; }+        match self.nets[net].plane.as_deref() {+            Some("F.Cu") => p.on_f,+            Some("B.Cu") => p.on_b,+            _ => false,+        }+    }+     /// Mark every cluster of the net in the same fixed-copper group as `c` connected; returns them.     pub fn connect_group(&mut self, net: usize, c: usize) -> Vec<usize> {         let comp = self.comp_of(net, c);@@ -345,4 +360,71 @@ pub(crate) mod tests {         assert!(r.nets[r.net_ids["A"]].done_by_board);         assert_eq!(nets_in(&plan), vec!["B".to_string()]);     }++    /// A zone of `net` over the whole board on `layer`, filled or not.+    fn full_zone(net: &str, layer: &str, filled: bool) -> String {+        let fill = if filled { format!("\t\t(filled_polygon\n\t\t\t(layer \"{layer}\")\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 19.5 0.5) (xy 19.5 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n") } else { String::new() };+        format!("\t(zone\n\t\t(net \"{net}\")\n\t\t(layer \"{layer}\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 19.5 0.5) (xy 19.5 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n{fill}\t)\n")+    }++    // issue #84: a mechanical carrier, four through-hole GND pins and GND filled on both layers, no+    // tracks or vias. The pour joins them: route plans nothing (it planned 393 mm of track).+    const PINS: [(&str, &str, f64, f64, bool); 4] = [("H1", "GND", 2.0, 2.0, true), ("H2", "GND", 18.0, 2.0, true), ("H3", "GND", 2.0, 8.0, true), ("H4", "GND", 18.0, 8.0, true)];++    #[test]+    fn filled_zones_alone_join_a_net_with_no_tracks_or_vias() {+        let zones = full_zone("GND", "F.Cu", true) + &full_zone("GND", "B.Cu", true);+        let (r, plan, report, failed) = route(board(&PINS, &zones));+        assert_eq!(failed, 0);+        assert!(plan.is_empty(), "{plan:?}");+        assert!(r.nets[r.net_ids["GND"]].done_by_board);+        assert_eq!(report["boardCopper"]["netsComplete"], serde_json::json!(["GND"]));+        assert_eq!(report["boardCopper"]["zoneIslands"], 2);+        assert!(report["boardCopper"]["warnings"].as_array().unwrap().is_empty());+    }++    #[test]+    fn a_fill_on_one_side_joins_only_the_pads_it_reaches() {+        // the F.Cu fill reaches the left pair only; the right pair is planned, and nothing more+        let left = "\t(zone\n\t\t(net \"GND\")\n\t\t(layer \"F.Cu\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 6 0.5) (xy 6 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon\n\t\t\t(layer \"F.Cu\")\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 6 0.5) (xy 6 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n\t)\n";+        let (r, _, report, failed) = route(board(&PINS, left));+        assert_eq!(failed, 0);+        let g = r.net_ids["GND"];+        assert!(!r.nets[g].done_by_board);+        assert_eq!(r.nets[g].routes.len(), 2, "three groups (the joined left pair, H2, H4): two connections");+        assert_eq!(report["boardCopper"]["netsPartial"], serde_json::json!(["GND"]));+    }++    #[test]+    fn an_unfilled_zone_of_a_tracked_net_is_warned() {+        let zones = full_zone("GND", "F.Cu", false) + &full_zone("GND", "B.Cu", false);+        let (_, plan, report, failed) = route(board(&PINS, &zones));+        assert_eq!(failed, 0);+        assert!(!plan.is_empty(), "no fill: nothing joins the pins yet");+        let w = report["boardCopper"]["warnings"].as_array().unwrap();+        assert_eq!(w.len(), 1, "{w:?}");+        let w = w[0].as_str().unwrap();+        assert!(w.starts_with("GND has a zone on B.Cu and F.Cu with no fill") && w.contains("refill"), "{w}");+    }++    #[test]+    fn a_pad_on_its_outer_layer_plane_gets_no_via() {+        // issue #84: planes {"GND": "F.Cu"} on a 2-layer board; TP1 is an F.Cu pad on the F.Cu plane+        let pads = [("H1", "GND", 2.0, 2.0, true), ("TP1", "GND", 10.0, 5.0, false), ("H2", "GND", 18.0, 8.0, true)];+        for (plane, vias) in [("F.Cu", 0usize), ("B.Cu", 1)] {+            let zone = full_zone("GND", plane, false);+            let mut rules = Rules::default();+            rules.planes.insert("GND".into(), plane.into());+            let mut r = Router::new(board(&pads, &zone), rules, false);+            let (plan, _, failed) = crate::drive::route_board(&mut r, 2, 4, 50);+            assert_eq!(failed, 0, "{plane}");+            let g = r.net_ids["GND"];+            let planned_vias = plan.iter().map(|e| e["paths"].to_string().matches("\"layer\"").count()).sum::<usize>();+            if vias == 0 {+                assert!(plan.is_empty() && r.nets[g].routes.is_empty(), "{plane}: {plan:?}");+            } else {+                assert!(planned_vias >= 1 || !r.nets[g].via_in_pad.is_empty(), "{plane}: an F.Cu pad reaches a B.Cu plane through a via: {plan:?}");+            }+        }+    } }
crates/aiflow-router/src/drive.rs+18−4
@@ -706,7 +706,7 @@ impl Router {         for c in 0..n {             let cl = self.nets[net].clusters[c].clone();             let key = cl.key(&self.board).to_string();-            if self.board.pads[cl.primary].thru || self.joined_by_board(net, c) {+            if self.on_plane(net, c) || self.joined_by_board(net, c) {                 self.nets[net].clusters[c].connected = true;                 continue;             }@@ -1030,7 +1030,7 @@ impl Router {             }             let n = self.nets[net].clusters.len();             for c in 0..n {-                let plane_thru = self.nets[net].plane.is_some() && self.board.pads[self.nets[net].clusters[c].primary].thru;+                let plane_thru = self.nets[net].plane.is_some() && self.on_plane(net, c);                 let touched = self.nets[net].routes.iter().any(|r| r.start.cluster() == Some(c) || (matches!(r.end, End::Pad(..) | End::Escape(..)) && r.end.cluster() == Some(c)));                 let done = self.nets[net].done_by_board || self.joined_by_board(net, c);                 self.nets[net].clusters[c].connected = touched || plane_thru || done;@@ -1207,9 +1207,23 @@ impl Router {             if joined {                 partial.push(net.name.clone());             }-            open += if net.plane.is_some() { (0..net.clusters.len()).filter(|&c| !self.joined_by_board(n, c) && !self.board.pads[net.clusters[c].primary].thru).count() } else { groups.len() - 1 };+            open += if net.plane.is_some() { (0..net.clusters.len()).filter(|&c| !self.joined_by_board(n, c) && !self.on_plane(n, c)).count() } else { groups.len() - 1 };         }-        json!({"trackPieces": self.fixed_tracks.len(), "vias": self.fixed_vias.len(), "netsComplete": complete, "netsPartial": partial, "openConnections": open})+        // a zone of a net the plan still tracks, with no fill in the board file: once refilled it+        // may join those pads itself, so say so instead of tracking across it in silence (issue #84)+        let zones = self.board.zones();+        let islands: usize = zones.iter().map(|z| z.fills.len()).sum();+        let mut warnings: Vec<String> = Vec::new();+        for net in &self.nets {+            if net.done_by_board || net.routes.is_empty() { continue; }+            let unfilled: Vec<&aiflow_board::Zone> = zones.iter().filter(|z| z.net == net.name && z.fills.is_empty()).collect();+            if unfilled.is_empty() { continue; }+            let mut layers: Vec<&str> = unfilled.iter().flat_map(|z| z.layers.iter().map(|&l| aiflow_board::LAYER_NAMES[l])).collect();+            layers.sort();+            layers.dedup();+            warnings.push(format!("{} has a zone on {} with no fill in the board file, and the plan tracks {} connection(s) of it: refill the zones (KiCad: Edit > Fill All Zones, save) and route again instead of tracking across the pour", net.name, layers.join(" and "), net.routes.len()));+        }+        json!({"trackPieces": self.fixed_tracks.len(), "vias": self.fixed_vias.len(), "zoneIslands": islands, "netsComplete": complete, "netsPartial": partial, "openConnections": open, "warnings": warnings})     } } 
crates/aiflow-router/src/lib.rs+1−1
@@ -579,7 +579,7 @@ impl Router {             if !self.comp_copper.is_empty() {                 let plane = self.nets[net].plane.is_some();                 let first = self.comp_of(net, 0);-                let done = if plane { (0..count).all(|c| self.joined_by_board(net, c) || self.board.pads[self.nets[net].clusters[c].primary].thru) } else { count >= 2 && (0..count).all(|c| self.comp_of(net, c) == first) };+                let done = if plane { (0..count).all(|c| self.joined_by_board(net, c) || self.on_plane(net, c)) } else { count >= 2 && (0..count).all(|c| self.comp_of(net, c) == first) };                 if done {                     self.nets[net].done_by_board = true;                     for c in 0..count {
flows/board.json+11−1
@@ -341,7 +341,7 @@   "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop",   "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one",   "sequence": {-    "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit. An Altium run also puts the parts on the wiki and the board on its project page before its library step (the library is built from those pages). kicad-new is a KiCad board from scratch (the run starts with no board): the parts go on the wiki and on the board's project page, KiCad installs them as one PCM library, and the schematic is drawn in KiCad before the board exists.",+    "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit. An Altium run also puts the parts on the wiki and the board on its project page before its library step (the library is built from those pages). kicad-new is a KiCad board from scratch (the run starts with no board): the parts go on the wiki and on the board's project page, KiCad installs them as one PCM library, and the schematic is drawn in KiCad before the board exists. pattern is a passive copper-pattern KiCad board with no parts, BOM, schematic or electrical load (`intake --kind pattern`, or requirements.json \"boardKind\": \"pattern\"): the electrical steps do not apply, finish runs the live native DRC with the fab rules and reports each step it left out and why. A board with pads on nets that need routing is refused this lane.",     "altium": [       "intake",       "rules",@@ -437,6 +437,16 @@       "3d",       "finish",       "export"+    ],+    "pattern": [+      "intake",+      "rules",+      "models",+      "outline",+      "silkscreen",+      "3d",+      "finish",+      "export"     ]   },   "proofs": {
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.14",+  "version": "0.2.15",   "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.2.14",+  "version": "0.2.15",   "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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+21−3
@@ -38,8 +38,8 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes.-6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.-4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and so is a via within the spec's hole-to-hole minimum (`holeToHole`, default 0.2 mm) of any via or hole, or within `clearance` of another via's copper, whatever its net (the ERROR names the clash), and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.@@ -77,6 +77,18 @@ draws an arc on the timeline, and every later step becomes a redo that must be d `finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never jump past it. +**Pattern boards (0.2.15, issue #84).** A passive copper-pattern board (a reflector disc, a carrier plate:+no parts, no BOM, no schematic, no electrical load) takes the `pattern` sequence: `intake --kind pattern`+(KiCad only), or `"boardKind": "pattern"` in requirements.json (a run still at `rules` switches over on its+next `step`). Steps: intake, rules, models, outline, silkscreen, 3d, finish, export. `start` still needs a+spec (`{}` is fine). finish skips the gate, land route, pours, measure and the analyses, keeps the fab+rules gate, the mechanical and silkscreen checks and the export re-read, requires the live full DRC (bind+the board on a test box), and prints each electrical step as not applicable with its reason. `step` and+finish refuse the lane for a board that needs routing: footprint pads on more than one net, on a net no+zone carries, tracks or vias on such a net, or a spec with loads, hot parts, signal checks or a converter.+Pads with no net or only on the single pour net (machine pins on a GND plate) are allowed; a sourcing+profile still applies to any footprint in the BOM.+ 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@@ -243,6 +255,12 @@ After the pours and the analyses: `adom-fields analyze --board <the current boar  **A switching converter needs `voltages`, `sources` and `switching` in the spec (issue #75).** A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence (`evidence add --kind calcs`) carries a switching frequency (`fsw`, `fsw_kHz`, `switching_frequency_Hz`, ...) or a converter `topology`. Fields models the cycle, the hot loop, emissions and the PDN only with the rails, the part feeding each output and the switching node with its frequency: `"voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}`. `start`, `evidence add --kind calcs` and `analyze` warn when the block is missing; `tour fields` (the step's proof) and `finish` refuse it, and also refuse a solve whose `fields.json` says it modelled no converter (`review.converter` null) or checked no rail's PDN (`review.pdn.rails` all skipped). The refusal prints the block filled from calcs.json (VIN, VOUT, fsw) and the board (switch, inductor, rails); `riseNs` 8 is adom-fields' default edge time, an estimate to replace from the regulator datasheet. Add the block, solve again, serve, `tour fields`. +**Nothing to solve (0.2.15, issue #84).** When the spec has no loads, no hot parts and no signal checks+(and the design is not a converter), `tour fields` records the fields step's proof as not applicable,+reason "no loads, hot parts or signal checks in the spec", the way thermal-vias records 0 vias for an empty+`spec.hot`. finish lists it as not applicable and refuses the record once the spec names any of them. Never+add a load only to get a solve.+ ## Time: sessions, not the wall clock  The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.@@ -267,7 +285,7 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and `  ## The spec -`docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.+`docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Vias of the pad's net already at the pad (any size, board vias or the footprint's own through-hole via pads) count toward `count`, as in `thermal-vias plan`, and no planned via comes within the hole-to-hole minimum or the via-to-via clearance of a via or hole already there. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.  ## Design gates (schematic, molecule, fab rules, 3D models, copper pads) 
skills/aiflow-power-layout/SKILL.md+2−1
@@ -47,7 +47,7 @@ Without a layer policy the grid router treats B.Cu as free space: on the buck it  `around` is the pad box of the listed parts or pads grown by `margin` (default 1.0 mm); `polygon: [[x, y], ...]` gives the region exactly. The router treats each region as a keepout for every other net on that layer and for other nets' vias, and says so in its hints; the gate fails on any other-net track, via or zone inside a region (`planeUnderIssues` in gate.json) and `finish` refuses until it is clear. -A region is a hard wall: a pin of another net inside it (the IC's EN or FB pin under U1) can only leave on the other layer, and a Kelvin sense may find no path. When `route` closes short with a region in place, shrink the margin, list fewer parts, or draw a polygon that leaves those pins a way out, before taking the nets by hand. If you do route by hand (`take route=ai`, `step routing --back --why "..."`): FB on F.Cu inside the quiet side, the VOUT sense along an edge, not across the plane, and land hand routes one at a time with a dry run first (`tools/route_live.py routes.json` calls `kicad_route_net` with `dryRun`, commits only on 0 DRC errors, and chains `expectedRevision` from each reply). After `adopt-board` of the reworked board, `route` keeps that copper and plans only what is still open (an empty plan when nothing is), and the gate checks the regions on it.+A region is a hard wall: a pin of another net inside it (the IC's EN or FB pin under U1) can only leave on the other layer, and a Kelvin sense may find no path. When `route` closes short with a region in place, shrink the margin, list fewer parts, or draw a polygon that leaves those pins a way out, before taking the nets by hand. If you do route by hand (`take route=ai`, `step routing --back --why "..."`): FB on F.Cu inside the quiet side, the VOUT sense along an edge, not across the plane, and land hand routes one at a time with a dry run first (`tools/route_live.py routes.json` calls `kicad_route_net` with `dryRun`, commits only on 0 DRC errors, and chains `expectedRevision` from each reply). After `adopt-board` of the reworked board, `route` keeps that copper and plans only what is still open (an empty plan when nothing is), and the gate checks the regions on it. Filled zones count as that copper: a net the board's pours already join is not tracked (refill unfilled zones first; `route` warns when it tracks a net that has one). A pad on its plane's own layer (`planes: {"GND": "F.Cu"}`, an F.Cu pad) gets no via.  Kelvin keepouts follow the tap's own layer (both layers only at its vias) and stop short of the power pin, so the IC GND pin keeps its pour (fixed in adom-aiflow 0.1.33; check older runs). @@ -56,6 +56,7 @@ Kelvin keepouts follow the tap's own layer (both layers only at its vias) and st - **SW is a small solid patch** from the IC's SW pin to the inductor, widening toward the inductor, never a large pour: small area, small radiated field, no neck. Open the polygon right after the pad; the first patch had a 0.95 mm throat at 60 A/mm2. - **Solid, not thermal-relief, at heat paths**: the IC GND pin and the inductor pads (`solidAt`). - **Thermal vias beside small pins.** A SOT-23 or 0603 pad is too small for a via; an open via in it wicks solder. Put 0.6/0.3 mm vias in the solid pour just outside the pad, clear of other nets (`pour` now does this and says how many of `count` fit).+- **Count the vias a pad already has.** `pour` counts the pad's existing vias (an exposed pad's datasheet in-pad pattern too) toward `thermalVias[].count` and keeps planned vias clear of them; `land vias` refuses a via within the hole-to-hole minimum or via-to-via clearance of any via or hole before anything lands. - **Check every landed via's net.** A planned GND via whose pad touched the SW trace landed as SW and DRC passed. After `land vias`, read each via's net on the live board against the plan.  ## 5. Current density: iterate with Fields
tests/board-kind-workflow.pyadded+71
@@ -0,0 +1,71 @@+"""Issues #82, #83, #84 through the CLI: silkscreen-audit shows the checker's own error, project-page+refuses a stale parts record by name, the fields step is recorded as not applicable on a spec with+nothing to solve, and the pattern lane (intake --kind pattern) with its refusal for boards that need+routing. No CAD, desktop or dashboard."""+import json, os, pathlib, subprocess, sys, tempfile+binary = str(pathlib.Path(sys.argv[1]).resolve())+HEAD = '(kicad_pcb (version 20240108) (net 0 "") (net 1 "GND") (net 2 "SIG") (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (gr_circle (center 10 10) (end 19 10) (layer "Edge.Cuts"))'+def pin(ref, x, net):+    n = {'GND': '(net 1 "GND")', 'SIG': '(net 2 "SIG")'}.get(net, '')+    return f' (footprint "MP" (layer "F.Cu") (at {x} 10) (property "Reference" "{ref}") (pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 1) (layers "*.Cu") {n}))'++with tempfile.TemporaryDirectory(prefix='aiflow-board-kind-') as td:+    p = pathlib.Path(td); env = dict(os.environ, AIFLOW_DASHBOARD='off')+    def call(run, *args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'board-kind-test', *args], env=env, capture_output=True, text=True, timeout=60)+    def attach(run, board, spec):+        state = run / 'run.json'; d = json.loads(state.read_text()); d['boards'] = [str(board)]; d['spec'] = str(spec); state.write_text(json.dumps(d))+    def data(run): return json.loads((run / 'run.json').read_text())+    spec = p / 'spec.json'; spec.write_text('{}')++    # --- #84 reply: the pattern lane -------------------------------------------------------------+    assert call(p / 'x', 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern', '--eda', 'fusion').returncode != 0, 'pattern is KiCad only'+    run = p / 'pattern'+    r = call(run, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern'); assert r.returncode == 0, r.stderr+    routed = p / 'routed.kicad_pcb'; routed.write_text(HEAD + pin('MP1', 5, 'GND') + pin('J1', 15, 'SIG') + ')\n')+    attach(run, routed, spec)+    r = call(run, 'step', 'rules'); assert r.returncode != 0 and '2 nets' in r.stdout + r.stderr, (r.stdout, r.stderr)+    disc = p / 'disc.kicad_pcb'; disc.write_text(HEAD + ' (gr_circle (center 10 10) (end 14 10) (layer "F.Cu") (width 0.5)))\n')+    attach(run, disc, spec)+    r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr)+    assert data(run)['sequence'] == ['intake', 'rules', 'models', 'outline', 'silkscreen', '3d', 'finish', 'export'], data(run)['sequence']+    r = call(run, 'finish'); out = r.stdout + r.stderr; assert r.returncode != 0+    assert 'native DRC' in out and 'land route' not in out and 'analyze-current' not in out and 'measure (' not in out, out+    # a run stuck at `rules` on the electrical sequence switches once requirements say boardKind pattern+    stuck = p / 'stuck'; assert call(stuck, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test').returncode == 0+    attach(stuck, disc, spec)+    assert call(stuck, 'step', 'rules').returncode == 0 and 'routing' in data(stuck)['sequence']+    req = p / 'requirements.json'; req.write_text(json.dumps({'boardKind': 'pattern'}))+    d = data(stuck); d['requirements'] = str(req); (stuck / 'run.json').write_text(json.dumps(d))+    r = call(stuck, 'step', 'rules'); assert r.returncode == 0, r.stderr+    assert data(stuck)['sequence'][2] == 'models', data(stuck)['sequence']++    # --- #84 part 1: fields not applicable ---------------------------------------------------------+    plate = p / 'plate.kicad_pcb'+    plate.write_text(HEAD + pin('MP1', 5, 'GND') + pin('MP2', 15, 'GND') + ' (zone (net 1) (net_name "GND") (layer "F.Cu") (uuid "z1") (polygon (pts (xy 1 1) (xy 19 1) (xy 19 19) (xy 1 19)))))\n')+    run = p / 'fields'; assert call(run, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test').returncode == 0+    attach(run, plate, spec)+    r = call(run, 'tour', 'fields'); assert r.returncode == 0 and 'not applicable' in r.stdout, (r.stdout, r.stderr)+    o = data(run)['outcomes']['tour-fields']; assert o['notApplicable'] and o['reason'] == 'no loads, hot parts or signal checks in the spec', o+    loaded = p / 'loaded.json'; loaded.write_text(json.dumps({'loads': {'GND': {'amps': 1}}}))+    attach(run, plate, loaded)+    # (a loaded spec goes on to the real Fields walkthrough, which needs the desktop: unit-tested in boardkind.rs)+    r = call(run, 'finish'); assert 'fields: recorded as not applicable, but now spec.loads names loads' in r.stdout + r.stderr, r.stdout + r.stderr++    # --- #82: silkscreen-audit shows the checker's error --------------------------------------------+    repro = p / 'audit.json'+    repro.write_text(json.dumps({"obstacles": [{"id": "R1:body", "kind": "body", "layer": "F.SilkS", "boxes": [[0, 0, 1, 1]]}],+                                 "inventory": [{"id": "R1:body", "kind": "body", "layers": ["F.SilkS"]}],+                                 "marks": [{"id": "R1", "layer": "F.SilkS", "text": "R1", "box": [2, 2, 3, 3]}]}))+    r = call(run, 'silkscreen-audit', '--input', str(repro), '--out', str(p / 'audit-out.json')); assert r.returncode == 0, (r.stdout, r.stderr)+    bad = json.loads(repro.read_text()); del bad['obstacles'][0]['boxes']; repro.write_text(json.dumps(bad))+    r = call(run, 'silkscreen-audit', '--input', str(repro), '--out', str(p / 'audit-out.json'))+    assert r.returncode != 0 and "obstacle 'R1:body' has neither 'box' nor 'boxes'" in r.stdout + r.stderr, (r.stdout, r.stderr)++    # --- #83 part 1: a stale parts record is named -------------------------------------------------+    bom = p / 'bom.csv'; bom.write_text('Ref,MPN\nMP1,PIN-1\n')+    d = data(run); d['outcomes']['parts'] = {'pass': True, 'bom': str(bom), 'parts': [{'mpn': 'PIN-1', 'refs': ['MP1'], 'wiki': 'adom/pin-1'}, {'mpn': 'RC0402FR-0733KL', 'refs': ['R9'], 'wiki': 'adom/rc0402'}]}+    (run / 'run.json').write_text(json.dumps(d))+    r = call(run, 'project-page', '--page', 'someone/board', '--no-push'); out = r.stdout + r.stderr+    assert r.returncode != 0 and 'part RC0402FR-0733KL is in the parts record but not in the BOM' in out and 'run `parts` again' in out.replace('Run `parts` again', 'run `parts` again'), out+print('PASS: pattern lane chosen and refused for a routed board, finish skips the electrical gates and asks for live DRC; fields n/a recorded and refused at finish once the spec names a load; silkscreen-audit names the bad item; stale parts record named')
tools/project-page.py+2
@@ -352,6 +352,8 @@ def merge_project_parts(parts, sym_text, fp_dir, m_dir, lib, pkgid):                 if name == pl["symbol"]:                     fail(f"project library symbol {name} ({p['mpn']}) has the same name as a symbol already in the library")                 continue+            if name not in spans:+                fail(f"part {p['mpn']} is in the parts record but its symbol {name} is missing from {pl['symbolFile']}: run `parts` again")             a, b = spans[name]             block = src[a:b]             if name == pl["symbol"]:
tools/silkscreen-associations.py+1−1
@@ -17,7 +17,7 @@ def associate(data):    for points in p['associationSegments']:graphics.append(dict(index=p['index'],reference=ref,layer=side,kind='outline',samples=points,contactBorder=True))    continue   if not ref or p.get('role')=='value':continue-  target=shape(data.get('targets',{}).get(str(p['index']),data['bodies'].get(side,{}).get(ref,{'type':'GeometryCollection','geometries':[]})))+  target=shape(data.get('targets',{})[str(p['index'])] if str(p['index']) in data.get('targets',{}) else data['bodies'].get(side,{}).get(ref,{'type':'GeometryCollection','geometries':[]}))   if target.is_empty:continue   target=target.buffer(data.get('targetMargin',.02))   own=unary_union([box(*q['box']) for q in children if q['layer']==side and q.get('reference')==ref and (not p.get('pin') or q['index']==p['index'])]);distance=own.distance(target)
tools/silkscreen-layout.py+7−4
@@ -14,6 +14,9 @@ from pathlib import Path  def overlap(a,b,gap=0):  return not(a[2]+gap<=b[0] or b[2]+gap<=a[0] or a[3]+gap<=b[1] or b[3]+gap<=a[1])+def rects_of(x):+ # "box" | "boxes"; never dict.get('boxes',[x['box']]), whose default is evaluated eagerly.+ return x['boxes'] if 'boxes' in x else [x['box']] def inside(a,b):return a[0]>=b[0] and a[1]>=b[1] and a[2]<=b[2] and a[3]<=b[3] def solve_milp(spec,groups):  import numpy as np@@ -48,7 +51,7 @@ def solve_milp(spec,groups):    h,b=candidates[j]    if h['id']==g['id']:continue    bad_order=g.get('orderGroup') and g.get('orderGroup')==h.get('orderGroup') and (g['order']-h['order'])*(a['position'][1]-b['position'][1])<=0-   conflict=bad_order or any(overlap(x,y,gap) for x in a.get('boxes',[a['box']]) for y in b.get('boxes',[b['box']]))+   conflict=bad_order or any(overlap(x,y,gap) for x in rects_of(a) for y in rects_of(b))    if conflict:row([i,j],-np.inf,1)  mat=coo_matrix((vv,(rr,cc)),shape=(len(lo),len(candidates))).tocsc();result=milp(c=-np.array([c['score'] for g,c in candidates]),integrality=np.ones(len(candidates)),bounds=Bounds(0,1),constraints=LinearConstraint(mat,lo,hi),options={'time_limit':spec.get('solverSeconds',45),'mip_rel_gap':.01})  if result.x is None:raise ValueError('No candidate layout found within MILP budget: '+result.message)@@ -56,7 +59,7 @@ def solve_milp(spec,groups):  return dict(schemaVersion=1,placements=chosen,unresolved=[x['id'] for x in chosen if x.get('unresolved')],score=-float(result.fun),method='bounded mixed-integer candidate search; finite candidate set only',solverStatus=result.message,boundsSource=spec.get('boundsSource'),nativeVerified=False) def solve(spec):  emit('start',total=len(spec['labels']),nativeVerified=False)- bounds=spec['bounds'];fixed=[x['box'] for x in spec.get('obstacles',[])];gap=spec.get('clearance',.12);width=int(spec.get('beamWidth',96));groups=[]+ bounds=spec['bounds'];fixed=[r for x in spec.get('obstacles',[]) for r in rects_of(x)];gap=spec.get('clearance',.12);width=int(spec.get('beamWidth',96));groups=[]  for g in spec['labels']:   cs=[];sampled=0   for c in g['candidates']:@@ -66,7 +69,7 @@ def solve(spec):     if 'reference' in roles and 'value' in roles and roles['value']['size']>roles['reference']['size']*g.get('maxValueRatio',.8):continue    if not all(math.isfinite(v) for v in c['box']) or c['box'][0]>=c['box'][2] or c['box'][1]>=c['box'][3]:raise ValueError('invalid bounds '+g['id'])    if math.dist(c['position'],g['anchor'])>g.get('maxDistance',math.inf):continue-   rects=c.get('boxes',[c['box']])+   rects=rects_of(c)    reason='outside board' if not all(inside(a,bounds) for a in rects) else 'intersects a reserved obstacle' if any(overlap(a,b,gap) for a in rects for b in fixed) else 'clears fixed obstacles; competing labels still to check'    if sampled<3:emit('candidate',id=g['id'],candidate=c,reason=reason);sampled+=1    if reason.startswith('clears'):cs.append(c)@@ -82,7 +85,7 @@ def solve(spec):   for score,chosen,boxes in beam:    for c in cs:     if g.get('orderGroup') and any(not x.get('unresolved') and x.get('orderGroup')==g['orderGroup'] and (x['order']-g['order'])*(x['position'][1]-c['position'][1])<=0 for x in chosen):continue-    rects=c.get('boxes',[c['box']])+    rects=rects_of(c)     if any(overlap(a,b,gap) for a in rects for b in boxes):continue     next_states.append((score+c['score'],chosen+[dict(id=g['id'],orderGroup=g.get('orderGroup'),order=g.get('order'),**c)],boxes+rects))    next_states.append((score-1e6,chosen+[dict(id=g['id'],unresolved=True)],boxes))
tools/silkscreen-obstacle-check.py+6−1
@@ -8,6 +8,11 @@ from pathlib import Path  def overlaps(a,b):  return a[0]<b[2] and b[0]<a[2] and a[1]<b[3] and b[1]<a[3]+def rects(item,what):+ # "box" | "boxes": dict.get(k, default) evaluates the default eagerly, so test the key.+ if 'boxes' in item:return item['boxes']+ if 'box' in item:return [item['box']]+ raise ValueError(f"{what} {item.get('id','?')!r} has neither 'box' nor 'boxes'") def check(data):  obs=data['obstacles'];inventory=data['inventory'];missing=[];collisions=[]  present={(o['id'],o['kind'],o['layer']) for o in obs}@@ -16,7 +21,7 @@ def check(data):    if (item['id'],item['kind'],layer) not in present:missing.append(dict(id=item['id'],kind=item['kind'],layer=layer))  for label in data['marks']:   for o in obs:-   if label['layer']==o['layer'] and any(overlaps(a,b) for a in label.get('boxes',[label['box']]) for b in o.get('boxes',[o['box']])):collisions.append(dict(mark=label['id'],obstacle=o['id'],kind=o['kind'],layer=o['layer']))+   if label['layer']==o['layer'] and any(overlaps(a,b) for a in rects(label,'mark') for b in rects(o,'obstacle')):collisions.append(dict(mark=label['id'],obstacle=o['id'],kind=o['kind'],layer=o['layer']))  return dict(schemaVersion=1,passed=bool(inventory) and not missing and not collisions,coverageErrors=[] if inventory else ['Whole-board inventory is empty'],missingObstacles=missing,collisions=collisions,inventoryItems=len(inventory),obstacles=len(obs),marks=len(data['marks']),nativeVerified=False,note='Conservative rectangle audit only; geometry provenance, fitted transforms, label association and native 2D/3D review remain required.') if __name__=='__main__':  p=argparse.ArgumentParser();p.add_argument('--input',required=True,type=Path);p.add_argument('--out',required=True,type=Path);a=p.parse_args();result=check(json.loads(a.input.read_text()));a.out.write_text(json.dumps(result,indent=2));print(('OK' if result['passed'] else 'ERROR')+f": {len(result['coverageErrors'])} coverage errors; {len(result['missingObstacles'])} missing obstacles; {len(result['collisions'])} collisions");raise SystemExit(0 if result['passed'] else 2)
tools/silkscreen-preflight.py+1−1
@@ -22,7 +22,7 @@ def check(data):    if not g.is_valid:errors.append(dict(kind='invalid-mark',index=index));continue    for k in tree.query(g):     o=obs[int(k)];other=geoms[int(k)]-    if g.intersection(other).area>1e-7:errors.append(dict(kind='obstacle-collision',index=index,mark=kind,obstacle=o.get('id',o['reference']),obstacleKind=o['kind'],layer=side))+    if g.intersection(other).area>1e-7:errors.append(dict(kind='obstacle-collision',index=index,mark=kind,obstacle=o['id'] if 'id' in o else o.get('reference'),obstacleKind=o['kind'],layer=side))   # Text rectangles may not overlap. Association lines also reserve their stroke width.   tree=STRtree([g for _,_,g in marks])   for k,(index,kind,g) in enumerate(marks):
tools/test-silkscreen-obstacle-check.py+9
@@ -13,5 +13,14 @@ class Audit(unittest.TestCase):  def test_bottom_text_does_not_hit_top_body(self):   d=dict(inventory=[dict(id='C4',kind='body',layers=['F.SilkS'])],obstacles=[dict(id='C4',kind='body',layer='F.SilkS',box=[0,0,2,1])],marks=[dict(id='label',layer='B.SilkS',box=[.1,.2,.3,.6])])   self.assertTrue(m.check(d)['passed'])+ def test_boxes_only_obstacle_issue_82(self):+  # Repro from adom/adom-aiflow#82: obstacle carries only "boxes"; used to raise KeyError: 'box'.+  d={"obstacles":[{"id":"R1:body","kind":"body","layer":"F.SilkS","boxes":[[0,0,1,1]]}],"inventory":[{"id":"R1:body","kind":"body","layers":["F.SilkS"]}],"marks":[{"id":"R1","layer":"F.SilkS","text":"R1","box":[2,2,3,3]}]}+  r=m.check(d);self.assertTrue(r['passed']);self.assertEqual(r['collisions'],[])+  d['marks'][0]={"id":"R1","layer":"F.SilkS","boxes":[[5,5,6,6],[.5,.5,.8,.8]]}+  self.assertEqual(len(m.check(d)['collisions']),1)+ def test_missing_geometry_names_the_item(self):+  d=dict(inventory=[dict(id='R1',kind='body',layers=['F.SilkS'])],obstacles=[dict(id='R1',kind='body',layer='F.SilkS')],marks=[dict(id='L',layer='F.SilkS',box=[0,0,1,1])])+  with self.assertRaisesRegex(ValueError,"obstacle 'R1' has neither"):m.check(d) unittest.main()