app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.2.59
19 files changed
+1164−32
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+1bin/adom-aiflowcrates/adom-aiflow/src/main.rs+102−8crates/adom-aiflow/src/route_quality.rs+20crates/adom-aiflow/src/route_variants.rs+5crates/adom-aiflow/src/thermalvias.rs+8crates/aiflow-copper/src/lib.rs+6crates/aiflow-copper/src/single_face.rs+382crates/aiflow-pours/src/lib.rs+8−2crates/aiflow-router/src/drive.rs+234−8crates/aiflow-router/src/lib.rs+44−1crates/aiflow-router/src/rules.rs+49package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+1tests/fixtures/single-layer.kicad_pcb+124tests/single-layer-workflow.py+167Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.58"+version = "0.2.59" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.58"+version = "0.2.59" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.58"+version = "0.2.59" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.58"+version = "0.2.59" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1@@ -55,6 +55,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 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. Every open connection is counted, also a net the router never got back to after a rip-up (reason "never routed"), so the summary and the variant ranking see it. **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged.+ **Single-face / no-via boards** (issue #183, a CNC-milled one-layer board of through-hole parts): spec `"routeLayers": ["B.Cu"]` (or `["F.Cu"]`, or both) limits the router's layers, and `"vias": false` forbids every via: a layer change is made only on a through-hole pad of the same net, where the lead joins both faces. With both layers and `"vias": false`, F.Cu copper is wire jumpers on the component side (the router takes one only when B.Cu has no way through). Both keys are optional; when the spec is silent, the project's `.kicad_dru` decides (`(constraint disallow via)` turns vias off, `disallow track` on one layer leaves the other) and a NOTE says so. The rules-used line shows the layers and vias; `route` says how many connections closed on the single face and lists every jumper (`singleFace` on the route outcome and in `plan-route-report.json`, `jumpers` in route quality). Plane nets are routed as tracks, and pours, `thermal-vias` and stitch vias plan no via. Paths are cut on the pad (one path per face), so `gate` and `land route` see no via; a hand-written plan with a via, or copper on a face the board does not route, is a gate finding and `land route` lands nothing. Connections that cannot close are findings with suggestions (allow F.Cu jumpers, move or rotate a part, widen the channel), never a refusal. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Pours (and their vias) that `pour` planned and that are not landed yet go into the gate copy, so a net only the planned pours join is not read as unconnected (`plannedPours` in gate.json). 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.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+102−8@@ -229,6 +229,8 @@ enum Cmd { #[arg(long, default_value_t = 600)] pause_ms: u64, /// parts whose body may overhang the outline (an edge connector; also spec placement.edgeOverhang); their pads stay inside #[arg(long = "allow-outside", value_delimiter = ',')] allow_outside: Vec<String> }, /// The grid router on the current board+ ///+ /// Single-face boards (issue #183): spec "routeLayers": ["B.Cu"] (or ["F.Cu"], or both) limits the router's layers, and "vias": false forbids every via: a layer change is made only on a through-hole pad of the same net, and with both layers F.Cu copper is wire jumpers on the component side. When the spec is silent, the project's .kicad_dru decides: (constraint disallow via) turns vias off and (constraint disallow track) on one layer leaves the other. The rules-used line shows the layers and vias; the report counts connections closed on one face and lists every jumper; open connections come back as findings with suggestions. Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize, /// route N variants (at most 8) with different levers (net order, via size, passes, seed), gate each one and keep the best by the gates alone; every variant stays on the route outcome #[arg(long, conflicts_with = "pick")] variants: Option<usize>,@@ -701,7 +703,60 @@ fn spec_of(r: &Run) -> Value { if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); } let spec = read_json(r.data["spec"].as_str().unwrap_or("")); guard_budgets(r, &spec);- spec_nets::align(spec, &r.current_board())+ let spec = spec_nets::align(spec, &r.current_board());+ // issue #183: the copper mode (spec routeLayers/vias, else the project's .kicad_dru) written into the+ // spec every step reads, so the router, pours and heatsink vias agree; a default mode changes nothing+ aiflow_copper::single_face::CopperMode::for_board(&spec, &r.current_board()).apply_to_spec(&spec)+}++/// Issue #183: the single-face record of a route: the router's counts (connections closed on the+/// primary face alone, with a jumper, layer changes on pads), the jumpers to fit by hand, and anything+/// in the plan the process cannot make (always empty for the router's own plan). Null in the default mode.+fn single_face_record(b: &Board, plan: &Value, report: &Value, mode: &aiflow_copper::single_face::CopperMode) -> (Value, String) {+ use aiflow_copper::single_face as sf;+ if mode.is_default() { return (Value::Null, String::new()); }+ let mut rec = if report["singleFace"].is_object() { report["singleFace"].clone() } else { json!({}) };+ rec["mode"] = json!(mode.describe());+ let issues = sf::plan_findings(b, plan, mode);+ rec["planIssues"] = json!(issues);+ let primary = aiflow_board::LAYER_NAMES[mode.primary_layer()];+ let unrouted = report["unrouted"].as_array().map(|a| a.len()).unwrap_or(0);+ let (n, single) = (rec["connections"].as_u64().unwrap_or(0), rec["singleLayer"].as_u64().unwrap_or(0));+ let mut line = format!("single face ({}): {single} of {n} connection(s) closed on {primary} alone", mode.describe());+ let mut list = String::new();+ if let Some(jl) = mode.jumper_layer() {+ let js = sf::jumpers(b, plan, jl);+ rec["jumpers"] = sf::jumpers_json(&js);+ line.push_str(&format!("; {} need a jumper ({} {} jumper(s), {} mm of wire on the component side)", rec["withJumper"].as_u64().unwrap_or(0), js.len(), aiflow_board::LAYER_NAMES[jl], rec["jumpers"]["lengthMm"]));+ for j in js.iter().take(12) { list.push_str(&format!("\n jumper {}: {} -> {} ({:.1} mm)", j.net, j.from, j.to, j.length_mm)); }+ if js.len() > 12 { list.push_str(&format!("\n ... and {} more (plan-route-report.json \"singleFace\".jumpers)", js.len() - 12)); }+ }+ line.push_str(&format!("; 0 vias; {unrouted} connection(s) left open{list}"));+ if !issues.is_empty() { line.push_str(&format!("\nWARNING the plan has {} thing(s) this board cannot make: {}", issues.len(), issues.join("; "))); }+ (rec, line)+}++/// Issue #183: suggestions for connections a single-face board could not close (never a refusal).+fn single_face_hints(mode: &aiflow_copper::single_face::CopperMode, unrouted: &[Value], b: &Board) -> Vec<String> {+ if mode.is_default() { return Vec::new(); }+ let mut out = Vec::new();+ if mode.jumper_layer().is_none() {+ let other = aiflow_board::LAYER_NAMES[1 - mode.primary_layer()];+ out.push(format!("Allow wire jumpers: \"routeLayers\": [\"{}\", \"{other}\"], \"vias\": false routes {other} copper as jumpers on the component side, changing face only on through-hole pads; route then lists every jumper to fit (add both keys to the spec; a .kicad_dru `disallow track` on {other} then flags the jumpers in DRC, so drop that rule when the jumpers are wires on purpose).", aiflow_board::LAYER_NAMES[mode.primary_layer()]));+ }+ let mut parts: Vec<String> = unrouted.iter().filter_map(|u| u.get(1).and_then(|x| x.get(0)).and_then(|k| k.as_str())).filter_map(|k| b.pad_by_key(k).map(|p| p.reference.clone())).collect();+ parts.sort(); parts.dedup();+ if !parts.is_empty() {+ out.push(format!("Move or rotate a part: on one face a net crosses another only through a jumper; {} {} the open connection(s): move or rotate {} so their nets stop crossing (`place pack`, `place check`), then route again.", parts.iter().take(8).cloned().collect::<Vec<_>>().join(", "), if parts.len() == 1 { "holds" } else { "hold" }, if parts.len() == 1 { "it" } else { "them" }));+ }+ out.push("Widen the channel: a track between two pads needs track + 2 x clearance of room; a narrower signal track (spec \"track\", keep \"wideNets\" for power), a part rotated so its rows run along the nets, or the free space under a module's body opens a way through.".into());+ out+}++/// Issue #183: the run's copper mode with where each setting came from (spec or the project's .kicad_dru).+fn copper_mode_of(r: &Run) -> aiflow_copper::single_face::CopperMode {+ let raw = r.data["spec"].as_str().filter(|p| Path::new(p).is_file()).map(read_json).unwrap_or(Value::Null);+ aiflow_copper::single_face::CopperMode::for_board(&raw, &r.current_board()) } /// Once placement has begun the spec's budgets are frozen (`spec_freeze`): freeze them on the first read, and@@ -3118,9 +3173,18 @@ fn continuation_report(r: &Run, evidence: Option<&Path>) -> Value { /// A routing plan read and normalised the one way both `gate` and `land` see it (issue #33): /// repeated waypoints and paths with no length are dropped; the notes name each drop. fn read_plan_normalised(path: &str, b: &Board) -> (Value, Vec<String>) {+ read_plan_mode(path, b, None)+}++/// `read_plan_normalised` for a board with a copper mode (issue #183): a plan for a board with no vias+/// says so, and the normalisation cuts its layer changes on through-hole pads into one path per face.+fn read_plan_mode(path: &str, b: &Board, mode: Option<&aiflow_copper::single_face::CopperMode>) -> (Value, Vec<String>) { // `{"nets": [...]}` or a bare array of entries: both become the object form first (issue #47) let mut v = aiflow_copper::normalize_plan(read_json(path)); if let Err(e) = aiflow_copper::plan_entries(&v) { err(&format!("{path}: {e}"), &[]); }+ if let Some(m) = mode.filter(|m| !m.is_default()) {+ if v.is_object() && v.get("vias").is_none() { v["vias"] = json!(false); v["routeLayers"] = json!(m.layer_names()); }+ } let notes = aiflow_copper::normalise_plan(b, &mut v); (v, notes) }@@ -4307,7 +4371,10 @@ fn main() { // issue #52: impedance targets set their nets' width (and pair pitch) and hold them to one layer let imp_choice = impedance_cmd::for_route(&b, &spec, &r.current_board(), &mut rules).unwrap_or_else(|(m, h)| err(&m, &h)); // issue #165: say the sizes the router actually uses, so a spec key it did not read shows at once- eprintln!("route rules used: track {} mm, clearance {} mm, via {} mm; per-net widths: wide {}, mid {}{}", rules.track, rules.clearance, rules.via,+ // issue #183: the copper mode (layers, vias) and where it came from, with any note on the .kicad_dru+ let mode = copper_mode_of(&r);+ for n in &mode.notes { note(n, &[]); }+ eprintln!("route rules used: track {} mm, clearance {} mm, via {} mm; {}; per-net widths: wide {}, mid {}{}", rules.track, rules.clearance, rules.via, mode.describe(), if rules.wide.is_empty() { "none".to_string() } else { rules.wide.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") }, if rules.mid.is_empty() { "none".to_string() } else { rules.mid.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") }, if spec.get("track").is_none() && spec.get("trackWidth").is_some() { " (track from the spec's trackWidth)" } else if spec.get("track").is_none() { " (track is the router default: the spec names no track)" } else { "" });@@ -4339,13 +4406,20 @@ fn main() { let dec = decoupling_check::review_route(&spec, &routed_on, &plan_doc, &report, r.outcomes().get("pour")); decoupling_check::record(&mut r, &dir, &dec); let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();+ // issue #183: what single-face routing made of the board, and the jumpers to fit by hand+ let (single_face, sf_line) = single_face_record(&routed_on, &plan_doc, &report, &mode);+ if !single_face.is_null() {+ report["singleFace"] = single_face.clone();+ write_json(&dir.join("plan-route-report.json"), &report);+ } r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None); let mut route_o = json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}); if !variants_rec.is_null() { route_o["variants"] = variants_rec; } if !report["impedance"].is_null() { route_o["impedance"] = report["impedance"].clone(); }+ if !single_face.is_null() { route_o["singleFace"] = single_face.clone(); } // issues #127/#128: route geometry and signal quality of the plan on this board, suggestions only let quality_lines = match route_quality::of_plan(&board_of(&r), &plan_doc, &spec) {- Some((qv, lines)) => { let qp = dir.join("plan-route-quality.json"); write_json(&qp, &qv); route_o["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string()); lines }+ Some((mut qv, mut lines)) => { route_quality::add_jumpers(&mut qv, &mut lines, &board_of(&r), &plan_doc, &mode); let qp = dir.join("plan-route-quality.json"); write_json(&qp, &qv); route_o["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string()); lines } None => Vec::new(), }; for l in &quality_lines { eprintln!("{l}"); }@@ -4381,7 +4455,11 @@ fn main() { } let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect(); let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };- findings(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+ let mut hints = vec![format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint];+ hints.extend(single_face_hints(&mode, &unrouted, &routed_on));+ hints.push("Or take the stage yourself: `adom-aiflow take route=ai`.".into());+ let sf = if sf_line.is_empty() { String::new() } else { format!("\n{sf_line}") };+ findings(&format!("routing closed {failed} connection(s) short after {passes} passes: {}{sf}", detail.join("; ")), &hints); } decoupling_check::refuse_errors("route", &dec); if has_copper && plan.is_empty() {@@ -4389,7 +4467,8 @@ fn main() { } else { 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() }; let imp_text = if imp_lines.is_empty() { String::new() } else { format!("\n{}", imp_lines.join("\n")) };- ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}{imp_text}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint, if imp_lines.is_empty() { String::new() } else { "After landing: `adom-aiflow analyze impedance` judges the landed copper (and a same-input Fields Signals solve); finish requires it.".into() }]);+ let sf = if sf_line.is_empty() { String::new() } else { format!("\n{sf_line}") };+ ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}{imp_text}{sf}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint, if imp_lines.is_empty() { String::new() } else { "After landing: `adom-aiflow analyze impedance` judges the landed copper (and a same-input Fields Signals solve); finish requires it.".into() }]); } } Cmd::Pour { plan } => {@@ -4468,7 +4547,8 @@ fn main() { p.display().to_string() }); // the same normalisation `land route` applies, so the gate passes only what lands (issue #33)- let (plan_v, plan_notes) = read_plan_normalised(&planp, &b);+ let mode = copper_mode_of(&r);+ let (plan_v, plan_notes) = read_plan_mode(&planp, &b, Some(&mode)); print_plan_notes(&plan_notes); let spec_g = spec_of(&r); if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) {@@ -4484,7 +4564,11 @@ fn main() { // the checks themselves are gate_eval::evaluate, shared with `route --variants` (issue #55) let base = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&e, &[])); let routed = dir.join("board-routed.kicad_pcb");- let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, quality, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[]));+ let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, mut quality, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[]));+ // issue #183: on a single-face board, the jumpers, and anything in the plan the process cannot make+ route_quality::add_jumpers(&mut quality.0, &mut quality.1, &b, &plan_v, &mode);+ let sf_issues = aiflow_copper::single_face::plan_findings(&b, &plan_v, &mode);+ if !mode.is_default() { s["singleFace"] = json!({"mode": mode.describe(), "issues": sf_issues, "jumpers": quality.0["jumpers"]}); } // issues #127/#128: route geometry and signal quality, reported with suggestions, never a refusal let qp = dir.join("route-quality.json"); write_json(&qp, &quality.0);@@ -4499,6 +4583,7 @@ fn main() { r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None); r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routeQuality": s["routeQuality"], "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes})); if !mol_errors.is_empty() { r.data["outcomes"]["gate"]["moleculeErrors"] = json!(mol_errors); }+ if !mode.is_default() { r.data["outcomes"]["gate"]["singleFaceIssues"] = json!(sf_issues.len()); } r.data["lastGate"] = json!({"at": r.data["outcomeAt"]["gate"], "board": r.current_board(), "boardRevision": r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1), "newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu, "plan": planp}); r.save().unwrap(); if !sw_issues.is_empty() {@@ -4538,6 +4623,9 @@ fn main() { for n in &m.severity_notes { eprintln!("Warning: {n}"); } if !m.errors().is_empty() { findings(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); } } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }+ if !sf_issues.is_empty() {+ findings(&format!("gate: DRC is recorded, but the plan has {} thing(s) this board's process cannot make ({}):\n {}", sf_issues.len(), mode.describe(), sf_issues.iter().take(12).cloned().collect::<Vec<_>>().join("\n ")), &["Route again (`adom-aiflow route` plans no vias and changes face only on through-hole pads), or edit the plan: end the path on a through-hole pad of the net and start the next one there on the other face.".into(), "If this board can have vias after all, say \"vias\": true in the spec (and drop `disallow via` from the .kicad_dru).".into()]);+ } let silk_n = s["silkAdvisories"].as_u64().unwrap_or(0) as usize; if silk_n > 0 { eprintln!("Warning: {}", silk_policy::advisory_line(silk_n, &s["silkAdvisoryList"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())); } if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); }@@ -4613,8 +4701,14 @@ fn main() { // the gate's normalisation (issue #33): no zero-length trace reaches the bridge, and // trace numbers (`--from`) count the same traces the gate passed let b = board_of(&r);- let (plan_main, plan_notes) = read_plan_normalised(&planp, &b);+ let mode = copper_mode_of(&r);+ let (plan_main, plan_notes) = read_plan_mode(&planp, &b, Some(&mode)); print_plan_notes(&plan_notes);+ // issue #183: a board with no vias gets none landed, and no copper on a face it does not route+ let sf_issues = aiflow_copper::single_face::plan_findings(&b, &plan_main, &mode);+ if !sf_issues.is_empty() {+ findings(&format!("land {what}: nothing landed; the plan has {} thing(s) this board's process cannot make ({}):\n {}", sf_issues.len(), mode.describe(), sf_issues.iter().take(12).cloned().collect::<Vec<_>>().join("\n ")), &["Route again (`adom-aiflow route` plans no vias on this board), gate, then land route.".into(), "If this board can have vias after all, say \"vias\": true in the spec.".into()]);+ } let entries: Vec<Value> = aiflow_copper::plan_entries(&plan_main).unwrap_or_else(|e| err(&format!("{planp}: {e}"), &[])); // a via must not touch another net's copper, planned or already on the board: KiCad // hands a touching via the other net and the bridge's DRC then sees nothing wrong
crates/adom-aiflow/src/route_quality.rs+20@@ -257,8 +257,28 @@ pub fn of_plan(b: &Board, plan: &Value, spec: &Value) -> Option<(Value, Vec<Stri Some(of_board(&rb, spec)) } +/// Issue #183: on a single-face board with wire jumpers, the jumpers (count, wire length, each one) go in+/// the quality report, with a line when there are any. Nothing changes in the default mode.+pub fn add_jumpers(v: &mut Value, lines: &mut Vec<String>, b: &Board, plan: &Value, mode: &aiflow_copper::single_face::CopperMode) {+ let Some(jl) = mode.jumper_layer() else { return };+ let js = aiflow_copper::single_face::jumpers(b, plan, jl);+ v["jumpers"] = aiflow_copper::single_face::jumpers_json(&js);+ if !js.is_empty() {+ lines.push(format!("route quality: {} wire jumper(s) on {} ({} mm on the component side); each is a wire to fit by hand: moving or rotating parts so nets stop crossing removes them", js.len(), aiflow_board::LAYER_NAMES[jl], v["jumpers"]["lengthMm"]));+ }+}+ /// A compact record for run outcomes (the full report sits in its own file). pub fn outcome(v: &Value, file: &str) -> Value {+ if v.get("jumpers").is_some() {+ let mut o = outcome_base(v, file);+ o["jumpers"] = v["jumpers"]["count"].clone();+ return o;+ }+ outcome_base(v, file)+}++fn outcome_base(v: &Value, file: &str) -> Value { json!({"report": file, "offAngle": v["offAngle"]["count"], "rightAngles": v["rightAngles"], "acute": v["acute"], "wiggles": v["wiggles"], "signalVias": v["signal"]["vias"], "signalNetsWithVias": v["signal"]["netsWithVias"], "signalNets": v["signal"]["nets"], "signalLengthMm": v["signal"]["lengthMm"], "medianDetour": v["signal"]["medianDetour"], "suggestions": v["suggestions"], "blocking": false}) }
crates/adom-aiflow/src/route_variants.rs+5@@ -43,6 +43,11 @@ pub fn route_once(b: Board, rules: &Rules, levers: &Levers, rip_per_net: usize, // 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; let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});+ // issue #183: a plan for a board with no vias says so, so gate and land route cut layer changes on pads the same way+ if !rules.default_copper_mode() {+ plan_doc["vias"] = json!(false);+ plan_doc["routeLayers"] = json!((0..2).filter(|&l| rules.layer_allowed(l)).map(|l| aiflow_board::LAYER_NAMES[l]).collect::<Vec<_>>());+ } if has_copper { let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0); plan_doc["boardCopper"] = bc.clone();
crates/adom-aiflow/src/thermalvias.rs+8@@ -154,6 +154,12 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a continue; } let net = h["tabNet"].as_str().unwrap_or("GND").to_string();+ // issue #183: a board with no vias (spec "vias": false, routeLayers on one face, or the project's+ // .kicad_dru disallow via) gets no heatsink vias; the copper on the part's own face carries the heat+ if spec["vias"].as_bool() == Some(false) {+ parts.push(Part { reference: reference.clone(), net, target: 0, existing: 0, planned: vec![], note: Some(format!("{reference}: this board has no vias (single-face or vias off), so no heatsink vias are planned; widen its copper on the routed face, or ack")), copper: vec![] });+ continue;+ } let tgt = target(watts, h["maxRiseC"].as_f64().unwrap_or(max_rise)); let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect(); let centres: Vec<(f64, f64)> = pads.iter().map(|p| (p.x, p.y)).collect();@@ -317,6 +323,8 @@ pub fn short_live(spec: &Value, b: &Board, live: &aiflow_bridge::landing::LiveCo } fn short_with(spec: &Value, b: &Board, vias: &[(String, f64, f64, f64)], live_pads: &dyn Fn(&str, &str) -> Option<Vec<(f64, f64)>>, acked: &[String]) -> Vec<(String, usize, usize)> {+ // issue #183: a board with no vias is never short of heatsink vias+ if spec["vias"].as_bool() == Some(false) { return Vec::new(); } let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0); // the same copper the planner read (issue #136: the board's filled zones too) let pours = copper_regions(spec, b);
crates/aiflow-copper/src/lib.rs+6@@ -10,6 +10,8 @@ use std::collections::{BTreeMap, HashMap}; use std::process::Command; +pub mod single_face;+ use aiflow_board::{num, Board, Pt}; use aiflow_grid::{Grid, Mask}; use serde_json::{json, Value};@@ -191,6 +193,10 @@ pub fn normalise_plan(board: &Board, plan: &mut Value) -> Vec<String> { keep_entries.push(e); } *entries = keep_entries;+ // issue #183: a plan for a board with no vias changes layer only on a through-hole pad, one path per face+ if plan.get("vias").and_then(|v| v.as_bool()) == Some(false) {+ notes.extend(single_face::split_at_pads(board, plan));+ } notes }
crates/aiflow-copper/src/single_face.rsadded+382@@ -0,0 +1,382 @@+//! Issue #183: single-face / no-via boards (a CNC-milled one-layer board of through-hole parts).+//!+//! Two optional spec keys, both defaulting to the two-layer router with vias:+//! - `"routeLayers": ["B.Cu"]` (or `["F.Cu"]`, or both): the copper layers the router may use;+//! - `"vias": false`: no via anywhere. A layer change is made only on a through-hole pad of the same+//! net, where the component lead joins both faces. With both layers listed, F.Cu copper is a wire+//! jumper on the component side and B.Cu the milled face.+//!+//! When the spec says nothing, the project's `.kicad_dru` decides: `(constraint disallow via)` turns+//! vias off and `(constraint disallow track)` on one layer leaves only the other. A rule with a+//! `condition` applies to some items only and is not read as a board-wide rule.+//!+//! This module resolves the mode, splits plan paths that change layer on a through-hole pad (one path+//! per face, no via), lists the jumpers, and names whatever in a plan the mode cannot make.++use aiflow_board::{geom, sx, Board, B_CU, F_CU, LAYER_NAMES};+use serde_json::{json, Value};++use crate::{plan_entries, point};++#[derive(Clone, Debug, PartialEq)]+pub struct CopperMode {+ /// May copper go on F.Cu, B.Cu?+ pub layers: [bool; 2],+ /// Vias anywhere (the default). Always false when only one layer is allowed.+ pub vias: bool,+ /// Where each setting came from: "default", "spec" or "kicad_dru".+ pub layers_from: &'static str,+ pub vias_from: &'static str,+ /// What the resolution did that the human should know (dru rules applied, a spec key overriding+ /// the dru, an unknown layer name ignored).+ pub notes: Vec<String>,+}++impl Default for CopperMode {+ fn default() -> CopperMode {+ CopperMode { layers: [true, true], vias: true, layers_from: "default", vias_from: "default", notes: Vec::new() }+ }+}++/// What a `.kicad_dru` disallows board-wide: vias, and tracks per layer (F.Cu, B.Cu).+#[derive(Clone, Debug, Default, PartialEq)]+pub struct DruDisallow {+ pub via: bool,+ pub track: [bool; 2],+ /// Rules that disallow something but only under a condition (not applied here), by name.+ pub conditional: Vec<String>,+}++/// Read the `disallow` constraints of a `.kicad_dru` text. A file that does not parse disallows nothing.+pub fn dru_disallow(text: &str) -> DruDisallow {+ let mut out = DruDisallow::default();+ let mut clean = String::with_capacity(text.len());+ for line in text.lines() {+ let mut quoted = false;+ let mut cut = line.len();+ for (i, c) in line.char_indices() {+ if c == '"' { quoted = !quoted; }+ if c == '#' && !quoted { cut = i; break; }+ }+ clean.push_str(&line[..cut]);+ clean.push('\n');+ }+ let Ok(root) = sx::parse(&format!("(dru {clean})")) else { return out };+ for rule in root.find_all("rule") {+ let kinds: Vec<String> = rule.find_all("constraint").filter(|c| c.atom(1) == Some("disallow"))+ .flat_map(|c| (2..c.items.len() + 1).filter_map(|i| c.atom(i).map(str::to_string)).collect::<Vec<_>>()).collect();+ if kinds.is_empty() { continue; }+ if rule.find("condition").is_some() {+ out.conditional.push(rule.atom(1).unwrap_or("?").to_string());+ continue;+ }+ if kinds.iter().any(|k| k == "via" || k == "through_via") { out.via = true; }+ if kinds.iter().any(|k| k == "track") {+ match rule.find("layer").and_then(|l| l.value()) {+ Some("F.Cu") => out.track[F_CU] = true,+ Some("B.Cu") => out.track[B_CU] = true,+ // no layer (every layer) or `outer`: not a routing rule a board can follow+ _ => {}+ }+ }+ }+ out+}++impl CopperMode {+ /// Both layers and vias: the two-layer router as it always was.+ pub fn is_default(&self) -> bool {+ self.layers == [true, true] && self.vias+ }++ pub fn layer_names(&self) -> Vec<&'static str> {+ (0..2).filter(|&l| self.layers[l]).map(|l| LAYER_NAMES[l]).collect()+ }++ /// With both layers and no vias, F.Cu is the jumper layer (wires on the component side).+ pub fn jumper_layer(&self) -> Option<usize> {+ (!self.vias && self.layers == [true, true]).then_some(F_CU)+ }++ /// The milled face: the one allowed layer, or B.Cu when F.Cu is jumpers.+ pub fn primary_layer(&self) -> usize {+ if !self.layers[B_CU] { F_CU } else { B_CU }+ }++ /// The mode from the spec, then the project's `.kicad_dru` text for what the spec leaves unsaid.+ pub fn resolve(spec: &Value, dru: Option<&str>) -> CopperMode {+ let mut m = CopperMode::default();+ let d = dru.map(dru_disallow).unwrap_or_default();+ // routeLayers+ match spec.get("routeLayers") {+ Some(Value::Array(a)) => {+ let mut layers = [false, false];+ for v in a {+ match v.as_str() {+ Some("F.Cu") => layers[F_CU] = true,+ Some("B.Cu") => layers[B_CU] = true,+ other => m.notes.push(format!("spec routeLayers: {} is not a layer the router uses (F.Cu, B.Cu); ignored", other.map(str::to_string).unwrap_or_else(|| v.to_string()))),+ }+ }+ if layers == [false, false] {+ m.notes.push("spec routeLayers names no layer the router uses; both F.Cu and B.Cu are routed".into());+ } else {+ m.layers = layers;+ m.layers_from = "spec";+ }+ }+ Some(v) => m.notes.push(format!("spec routeLayers should be a list such as [\"B.Cu\"], not {v}; both layers are routed")),+ None => {}+ }+ if m.layers_from == "default" && d.track[F_CU] != d.track[B_CU] {+ let keep = if d.track[F_CU] { B_CU } else { F_CU };+ m.layers = [keep == F_CU, keep == B_CU];+ m.layers_from = "kicad_dru";+ m.notes.push(format!("the project's .kicad_dru disallows tracks on {}: routing on {} only (say \"routeLayers\" in the spec to choose otherwise)", LAYER_NAMES[1 - keep], LAYER_NAMES[keep]));+ } else if m.layers_from == "spec" {+ for l in 0..2 {+ if m.layers[l] && d.track[l] {+ m.notes.push(format!("spec routeLayers allows {} but the project's .kicad_dru disallows tracks there: KiCad's DRC (the gate) will report every {} track{}", LAYER_NAMES[l], LAYER_NAMES[l], if !m.vias || d.via { " (the jumpers); drop that rule from the .kicad_dru when the jumpers are wires on purpose" } else { "" }));+ }+ }+ }+ // vias+ match spec.get("vias") {+ Some(Value::Bool(b)) => {+ m.vias = *b;+ m.vias_from = "spec";+ if *b && d.via { m.notes.push("spec \"vias\": true, but the project's .kicad_dru has (constraint disallow via): the router plans vias and KiCad's DRC (the gate) will report each one".into()); }+ }+ Some(v) => m.notes.push(format!("spec vias should be true or false, not {v}; ignored")),+ None => {+ if d.via {+ m.vias = false;+ m.vias_from = "kicad_dru";+ m.notes.push("the project's .kicad_dru has (constraint disallow via): no vias are planned, and a layer change is made only on a through-hole pad of the same net (say \"vias\": true in the spec to choose otherwise)".into());+ }+ }+ }+ if !d.conditional.is_empty() {+ m.notes.push(format!("the project's .kicad_dru has disallow rule(s) with a condition ({}): they apply to some items only and do not change the router's layers or vias; KiCad's DRC still checks them", d.conditional.join(", ")));+ }+ if m.layers != [true, true] && m.vias {+ // one face: a via has nothing to join+ if m.vias_from == "spec" { m.notes.push(format!("spec \"vias\": true with routeLayers {}: one layer has nothing for a via to join; no vias are planned", m.layer_names().join(", "))); }+ m.vias = false;+ if m.vias_from == "default" { m.vias_from = m.layers_from; }+ }+ m+ }++ /// The mode for a board: the spec, then `<board>.kicad_dru` beside it.+ pub fn for_board(spec: &Value, board_path: &str) -> CopperMode {+ let dru = std::fs::read_to_string(std::path::Path::new(board_path).with_extension("kicad_dru")).ok();+ CopperMode::resolve(spec, dru.as_deref())+ }++ /// The spec with the resolved mode written in (`routeLayers`, `vias`), so every step that reads the+ /// spec (the router rules, pours, heatsink vias) sees what the .kicad_dru decided. A default mode+ /// returns the spec unchanged.+ pub fn apply_to_spec(&self, spec: &Value) -> Value {+ let mut s = spec.clone();+ if self.is_default() || !s.is_object() { return s; }+ s["routeLayers"] = json!(self.layer_names());+ s["vias"] = json!(false);+ s+ }++ /// The words for the rules-used line: "layers B.Cu, vias off (kicad_dru)".+ pub fn describe(&self) -> String {+ let src = |f: &str| if f == "default" { String::new() } else { format!(" ({})", if f == "spec" { "spec" } else { "project .kicad_dru" }) };+ let layers = format!("layers {}{}", self.layer_names().join(" + "), src(self.layers_from));+ let vias = if self.vias { "vias on".to_string() } else {+ format!("vias off{}: a layer change only on a through-hole pad of the same net{}", src(self.vias_from), match self.jumper_layer() { Some(j) => format!("; {} copper is wire jumpers on the component side", LAYER_NAMES[j]), None => String::new() })+ };+ format!("{layers}, {vias}")+ }+}++/// Is (x, y) on a through-hole pad of `net` (inside its copper or its drill)? Returns the pad index.+pub fn pth_pad_at(board: &Board, net: &str, x: f64, y: f64) -> Option<usize> {+ board.pads.iter().position(|p| p.thru && p.net == net && (p.polys.iter().any(|poly| geom::point_in_poly(x, y, poly)) || p.drill.is_some_and(|d| (p.x - x).hypot(p.y - y) <= d / 2.0 + 1e-6)))+}++/// Split every path of a no-via plan where it changes layer on a through-hole pad of its net: the+/// path ends on the pad on one face and the next starts on the pad on the other, so no via is made+/// there (the component lead joins the faces). A layer change anywhere else is left for the gate to+/// report. Returns one note per split.+pub fn split_at_pads(board: &Board, plan: &mut Value) -> Vec<String> {+ let mut notes = Vec::new();+ let Some(entries) = plan.get_mut("nets").and_then(|n| n.as_array_mut()) else { return notes };+ for (ei, e) in entries.iter_mut().enumerate() {+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+ let entry_layer = e.get("layer").and_then(|l| l.as_str()).map(str::to_string);+ let key = if e.get("paths").map(|p| p.is_array()).unwrap_or(false) { "paths" } else if e.get("points").map(|p| p.is_array()).unwrap_or(false) { "points" } else { continue };+ let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };+ let mut out: Vec<Value> = Vec::new();+ let mut changed = 0;+ for path in paths {+ let Some(pts) = path.as_array() else { out.push(path); continue };+ let Ok(res) = pts.iter().map(|w| point(w, board)).collect::<Result<Vec<_>, _>>() else { out.push(path.clone()); continue };+ let mut layer = entry_layer.clone().or_else(|| res.first().and_then(|r| r.3.clone()))+ .unwrap_or_else(|| Board::path_start_layer(res.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());+ let mut cur: Vec<Value> = Vec::new();+ for (k, (wp, (x, y, pad, explicit))) in pts.iter().zip(&res).enumerate() {+ let nxt = explicit.clone().unwrap_or_else(|| layer.clone());+ let on_pth = pad.filter(|&i| board.pads[i].thru && board.pads[i].net == net).or_else(|| pth_pad_at(board, &net, *x, *y));+ if k > 0 && nxt != layer && on_pth.is_some() {+ let pi = on_pth.unwrap();+ let pk = board.pads[pi].key.clone();+ let ambiguous = board.key_is_ambiguous(&pk);+ let here = |l: Option<&str>| -> Value {+ match (ambiguous || pad.is_none(), l) {+ (true, Some(l)) => json!({"x": x, "y": y, "layer": l}),+ (true, None) => json!([x, y]),+ (false, Some(l)) => json!({"pad": pk, "layer": l}),+ (false, None) => json!(pk),+ }+ };+ cur.push(here(None));+ out.push(Value::Array(std::mem::take(&mut cur)));+ cur.push(here(Some(&nxt)));+ changed += 1;+ } else {+ cur.push(wp.clone());+ }+ layer = nxt;+ }+ out.push(Value::Array(cur));+ }+ if changed > 0 {+ notes.push(format!("{net} (entry {}): {changed} layer change(s) on a through-hole pad of the net made without a via (vias off: the path is cut on the pad, one path per face)", ei + 1));+ out.retain(|p| p.as_array().map(|a| a.len() >= 2).unwrap_or(true));+ if key == "paths" { e["paths"] = Value::Array(out); } else {+ e.as_object_mut().unwrap().remove("points");+ e["paths"] = Value::Array(out);+ }+ }+ }+ notes+}++/// One jumper: a run of copper on the jumper layer, end to end.+#[derive(Clone, Debug, PartialEq)]+pub struct Jumper {+ pub net: String,+ pub from: String,+ pub to: String,+ pub length_mm: f64,+}++fn place_name(board: &Board, net: &str, x: f64, y: f64, pad: Option<usize>) -> String {+ match pad.or_else(|| pth_pad_at(board, net, x, y)) {+ Some(i) => board.pads[i].key.clone(),+ None => format!("({:.2}, {:.2})", x, y),+ }+}++/// Every run of plan copper on `layer` (the jumper layer): one per path stretch on that layer.+pub fn jumpers(board: &Board, plan: &Value, layer: usize) -> Vec<Jumper> {+ let mut out = Vec::new();+ let want = LAYER_NAMES[layer];+ for e in plan_entries(plan).unwrap_or_default() {+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+ let paths: Vec<Value> = match e.get("paths").and_then(|p| p.as_array()) { Some(p) => p.clone(), None => e.get("points").cloned().into_iter().collect() };+ for path in paths {+ let Some(pts) = path.as_array() else { continue };+ let Ok(res) = pts.iter().map(|w| point(w, board)).collect::<Result<Vec<_>, _>>() else { continue };+ let mut l = e.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| res.first().and_then(|r| r.3.clone()))+ .unwrap_or_else(|| Board::path_start_layer(res.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());+ let mut run: Option<(usize, f64)> = None;+ for k in 0..res.len() {+ let (x, y, _, ex) = &res[k];+ if k > 0 {+ let (px, py) = (res[k - 1].0, res[k - 1].1);+ if l == want {+ let r = run.get_or_insert((k - 1, 0.0));+ r.1 += (x - px).hypot(y - py);+ }+ }+ let nxt = ex.clone().unwrap_or_else(|| l.clone());+ if nxt != l && l == want {+ if let Some((s, len)) = run.take() { if len > 1e-6 { out.push(Jumper { net: net.clone(), from: place_name(board, &net, res[s].0, res[s].1, res[s].2), to: place_name(board, &net, *x, *y, res[k].2), length_mm: len }); } }+ }+ l = nxt;+ }+ if let Some((s, len)) = run.take() {+ let k = res.len() - 1;+ if len > 1e-6 { out.push(Jumper { net: net.clone(), from: place_name(board, &net, res[s].0, res[s].1, res[s].2), to: place_name(board, &net, res[k].0, res[k].1, res[k].2), length_mm: len }); }+ }+ }+ }+ out+}++/// The jumpers as JSON for reports: count, total length, and the list.+pub fn jumpers_json(js: &[Jumper]) -> Value {+ json!({"count": js.len(), "lengthMm": (js.iter().map(|j| j.length_mm).sum::<f64>() * 100.0).round() / 100.0,+ "list": js.iter().map(|j| json!({"net": j.net, "from": j.from, "to": j.to, "lengthMm": (j.length_mm * 100.0).round() / 100.0})).collect::<Vec<_>>()})+}++/// What a plan has that this mode cannot make: vias (with vias off), and copper on a layer the mode+/// leaves out. One line per finding; empty when the plan fits the process (or the mode is the default).+pub fn plan_findings(board: &Board, plan: &Value, mode: &CopperMode) -> Vec<String> {+ let mut out = Vec::new();+ if mode.is_default() { return out; }+ for e in plan_entries(plan).unwrap_or_default() {+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("?");+ let Ok((segs, vias)) = crate::plan_to_copper(board, &e) else { continue };+ if !mode.vias {+ for v in &vias {+ out.push(format!("{net}: a via at ({:.2}, {:.2}); this board has no vias (move the layer change onto a through-hole pad of {net}, or keep the path on one face)", v.x, v.y));+ }+ }+ for l in 0..2 {+ if mode.layers[l] { continue; }+ let n = segs.iter().filter(|s| s.layer == LAYER_NAMES[l]).count();+ if n > 0 {+ out.push(format!("{net}: {n} track segment(s) on {}, which this board does not route (routeLayers {})", LAYER_NAMES[l], mode.layer_names().join(", ")));+ }+ }+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn dru_disallow_via_and_track_on_a_layer() {+ let d = dru_disallow("(version 1)\n# milled\n(rule \"no vias\" (constraint disallow via))\n(rule \"no top copper\" (layer F.Cu) (constraint disallow track zone))\n(rule \"cond\" (condition \"A.NetClass == 'HV'\") (constraint disallow via))\n");+ assert!(d.via);+ assert_eq!(d.track, [true, false]);+ assert_eq!(d.conditional, vec!["cond".to_string()]);+ assert_eq!(dru_disallow("(version 1)\n(rule \"c\" (constraint clearance (min 0.2mm)))"), DruDisallow::default());+ assert_eq!(dru_disallow("not a rules file"), DruDisallow::default());+ }++ #[test]+ fn resolve_default_spec_and_dru() {+ assert!(CopperMode::resolve(&json!({}), None).is_default());+ assert!(CopperMode::resolve(&json!({"routeLayers": ["F.Cu", "B.Cu"], "vias": true}), None).is_default());+ let m = CopperMode::resolve(&json!({"routeLayers": ["B.Cu"]}), None);+ assert_eq!((m.layers, m.vias), ([false, true], false));+ let m = CopperMode::resolve(&json!({"vias": false}), None);+ assert_eq!((m.layers, m.vias, m.jumper_layer()), ([true, true], false, Some(F_CU)));+ // the dru when the spec is silent, with a note+ let dru = "(version 1)\n(rule \"v\" (constraint disallow via))\n(rule \"t\" (layer \"F.Cu\") (constraint disallow track zone))\n";+ let m = CopperMode::resolve(&json!({}), Some(dru));+ assert_eq!((m.layers, m.vias, m.layers_from, m.vias_from), ([false, true], false, "kicad_dru", "kicad_dru"));+ assert_eq!(m.notes.len(), 2, "{:?}", m.notes);+ // the spec wins over the dru, and says what DRC will report+ let m = CopperMode::resolve(&json!({"routeLayers": ["F.Cu", "B.Cu"]}), Some(dru));+ assert_eq!((m.layers, m.vias, m.jumper_layer()), ([true, true], false, Some(F_CU)));+ assert!(m.notes.iter().any(|n| n.contains("will report every F.Cu track")), "{:?}", m.notes);+ let m = CopperMode::resolve(&json!({"vias": true}), Some("(version 1)\n(rule \"v\" (constraint disallow via))"));+ assert!(m.is_default() && m.notes.iter().any(|n| n.contains("will report each one")));+ assert!(CopperMode::resolve(&json!({"routeLayers": ["In1.Cu"]}), None).is_default());+ assert_eq!(CopperMode::resolve(&json!({"routeLayers": ["B.Cu"]}), None).apply_to_spec(&json!({"a": 1})), json!({"a": 1, "routeLayers": ["B.Cu"], "vias": false}));+ assert_eq!(CopperMode::default().apply_to_spec(&json!({"a": 1})), json!({"a": 1}));+ }+}
crates/aiflow-pours/src/lib.rs+8−2@@ -312,7 +312,13 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ holes.extend(planned.iter().cloned()); let mut vias: Vec<Value> = Vec::new(); let via_entry = |net: &str, x: f64, y: f64, size: f64, drill: f64| json!({"net": net, "width": 0.5, "viaSize": size, "viaDrill": drill, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]});- for tv in spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ // issue #183: a board with no vias (spec "vias": false, one routed face, or the project's .kicad_dru)+ // gets no thermal or stitching vias+ let no_vias = spec.get("vias").and_then(|v| v.as_bool()) == Some(false);+ let via_asks = spec.get("thermalVias").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0) + spec.get("stitchVias").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);+ if no_vias && via_asks > 0 { eprintln!("pours: this board has no vias (single-face or vias off): the spec's {via_asks} thermalVias/stitchVias entr{} left out", if via_asks == 1 { "y is" } else { "ies are" }); }+ let empty: Vec<Value> = Vec::new();+ for tv in if no_vias { empty.clone() } else { 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"):@@ -402,7 +408,7 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ } // a stitch via with no size and drill takes the spec's signal via, else the board's own (issue #90) let (dsize, ddrill, dfrom) = stitch_via_default(spec, board);- for sv in spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ for sv in if no_vias { empty.clone() } else { 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(dsize), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(ddrill)); let sized = if sv.get("size").is_some() { String::new() } else { format!(" ({size}/{drill} mm from {dfrom}; give the entry \"size\" and \"drill\" in mm for another via)") };
crates/aiflow-router/src/drive.rs+234−8@@ -7,7 +7,7 @@ use aiflow_board::{geom, B_CU, F_CU}; use aiflow_grid::RES; use serde_json::{json, Value}; -use crate::{slop, turn_cost, End, NetState, Route, Router, Snapshot, Term, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, PAIR_OFF, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};+use crate::{slop, turn_cost, End, NetState, Route, Router, Snapshot, Term, COST_VIA, DIRS, HIST_COST, INF, JUMPER_COST, JUMPER_LAYER_MULT, LAYER_MULT, PAIR_OFF, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST}; pub type Win = (i64, i64, i64, i64); @@ -114,7 +114,13 @@ impl Router { } hp.iter().map(|(hi, hj)| { let dx = (hi - il).abs(); let dy = (hj - jl).abs(); 10 * dx.max(dy) + 4 * dx.min(dy) }).min().unwrap() };+ // issue #183: with both layers and no vias, the jumper layer (F.Cu) is a wire on the component+ // side: entering it costs JUMPER_COST, so a connection takes a jumper only when the milled face+ // has no way through+ let jumper = self.rules.jumper_layer();+ let layer_mult: [f64; 2] = if jumper.is_some() { JUMPER_LAYER_MULT } else { LAYER_MULT }; for (t, c0) in &s.sources {+ let c0 = &(c0 + if jumper == Some(t.layer()) { JUMPER_COST } else { 0 }); let (i, j) = (t.i as i64, t.j as i64); if !(i >= i0 && i <= i1 && j >= j0 && j <= j1) { continue;@@ -153,7 +159,7 @@ impl Router { let il = (m % ww) as i64; let gn = gcost[n]; let pd = pdir[n];- let mult = LAYER_MULT[l];+ let mult = layer_mult[l]; for d in 0..8 { let ni = il + DIRS[d].0 as i64; let nj = jl + DIRS[d].1 as i64;@@ -206,7 +212,7 @@ impl Router { if !obs_o[l2].bits[m] { let nn = l2 * nl + m; if !closed[nn] {- let ng = gn + COST_VIA + VIA_WALL * wall[m];+ let ng = gn + COST_VIA + VIA_WALL * wall[m] + if jumper == Some(l2) { JUMPER_COST } else { 0 }; if ng < gcost[nn] { gcost[nn] = ng; parent[nn] = n;@@ -324,6 +330,7 @@ impl Router { let mut allpts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect(); allpts.extend_from_slice(anchor); let mut vip_src = Vec::new();+ let vip = vip && self.rules.vias_allowed(); // issue #183: via-in-pad is a via if vip { vip_src = self.vip_cells(net, &[cluster]); let connected: Vec<usize> = self.nets[net].clusters.iter().filter(|c| c.connected).map(|c| c.idx).collect();@@ -353,7 +360,7 @@ impl Router { // top pad reached on B.Cu through via-in-pad); a bottom part's pad reached on B.Cu // is already joined, and a via there put the path's end on F.Cu, where it has none let ep = &self.board.pads[self.nets[net].clusters[ec].primary];- if !ep.thru && !ep.on_layer(end_cell.layer()) {+ if !ep.thru && !ep.on_layer(end_cell.layer()) && self.rules.vias_allowed() { route.via_end = true; route.via_end_layer = if ep.on_f { F_CU } else { B_CU }; self.stat("viaInPad", 1);@@ -430,7 +437,7 @@ impl Router { remaining.retain(|&x| x != c); let anchor: Vec<(i64, i64)> = self.nets[net].clusters[k].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect(); let (mut route, _) = self.connect(net, c, &anchor, false, false);- if route.is_none() {+ if route.is_none() && self.rules.vias_allowed() { route = self.connect(net, c, &anchor, false, true).0; } match route {@@ -441,7 +448,7 @@ impl Router { self.nets[net].last_soft.extend(sr.cells.iter().copied()); } let key = self.nets[net].clusters[c].key(&self.board).to_string();- let why = if blockers.is_empty() { "no path".to_string() } else {+ let why = if let Some(w) = self.off_layer_reason(net, c).or_else(|| self.off_layer_reason(net, k)) { w } else if blockers.is_empty() { if self.rules.default_copper_mode() { "no path".to_string() } else { format!("no path {}", self.mode_words()) } } else { let mut names: Vec<String> = blockers.iter().map(|&b| self.name_of(b)).collect(); names.sort(); format!("blocked by {names:?}")@@ -463,6 +470,30 @@ impl Router { blockers_all } + /// Issue #183: why a cluster cannot be reached in single-face mode: its pad has copper only on a+ /// layer the router may not use. None when the pad is reachable (or in the default mode).+ pub fn off_layer_reason(&self, net: usize, c: usize) -> Option<String> {+ if self.rules.default_copper_mode() { return None; }+ let p = &self.board.pads[self.nets[net].clusters[c].primary];+ if p.thru || (0..2).any(|l| p.on_layer(l) && self.rules.layer_allowed(l)) { return None; }+ let on = if p.on_f { "F.Cu" } else { "B.Cu" };+ Some(format!("pad {} has copper only on {on}, a layer this board does not route (routeLayers {}); allow {on}, or use a through-hole part there", p.key, self.mode_layers().join(" + ")))+ }++ /// The layers the router may use, by name.+ pub fn mode_layers(&self) -> Vec<&'static str> {+ (0..2).filter(|&l| self.rules.layer_allowed(l)).map(|l| aiflow_board::LAYER_NAMES[l]).collect()+ }++ /// "on B.Cu alone, no vias" or "on B.Cu with F.Cu jumpers, no vias".+ pub fn mode_words(&self) -> String {+ match self.rules.jumper_layer() {+ Some(j) => format!("on {} with {} jumpers, no vias", aiflow_board::LAYER_NAMES[1 - j], aiflow_board::LAYER_NAMES[j]),+ None if !self.rules.vias_allowed() => format!("on {} alone, no vias", self.mode_layers().join(" + ")),+ None => String::new(),+ }+ }+ pub fn name_of(&self, nid: usize) -> String { if let Some(&owner) = self.kelvin_owner.get(&nid) { return self.nets[owner].name.clone();@@ -1182,6 +1213,41 @@ impl Router { pts } + /// Issue #183: a route that changes layer at through-hole pads of its net, cut into one route per+ /// face. Each cut ends and starts on the pad (the lead joins the faces), so no via is planned.+ pub fn split_at_pads(&self, net: usize, route: &Route) -> Vec<Route> {+ let cells = &route.cells;+ let cuts: Vec<usize> = (1..cells.len()).filter(|&k| cells[k].layer() != cells[k - 1].layer()).collect();+ if cuts.is_empty() {+ return vec![route.clone()];+ }+ let mut out = Vec::new();+ let mut start = route.start.clone();+ let mut from = 0usize;+ for &k in cuts.iter().chain(std::iter::once(&cells.len())) {+ let piece: Vec<Term> = cells[from..k].to_vec();+ let end = if k == cells.len() { route.end.clone() } else {+ let t = cells[k - 1];+ match self.pth_cluster_at(net, t.i, t.j) { Some(cl) => End::Pad(cl, t), None => End::Copper }+ };+ let next_start = if k < cells.len() {+ let t = cells[k];+ match self.pth_cluster_at(net, t.i, t.j) { Some(cl) => End::Pad(cl, t), None => End::Copper }+ } else { End::Copper };+ if piece.len() >= 2 {+ let mut r = Route::new(piece, route.width, start.clone(), end);+ r.kelvin = route.kelvin;+ r.pair = route.pair;+ r.breaks = route.breaks.iter().filter(|&&b| b >= from && b < k).map(|b| b - from).collect();+ if k == cells.len() { r.via_end = route.via_end; r.via_end_layer = route.via_end_layer; }+ out.push(r);+ }+ start = next_start;+ from = k;+ }+ out+ }+ pub fn plan(&self) -> Vec<Value> { let mut order: Vec<usize> = (0..self.nets.len()).collect(); order.sort_by_key(|&n| (self.nets[n].routed_order.unwrap_or(1_000_000), self.nets[n].name.clone()));@@ -1193,6 +1259,21 @@ impl Router { } let mut by_width: Vec<(f64, Vec<Value>)> = Vec::new(); for r in &net.routes {+ // issue #183: with vias off a route that changes layer at a through-hole pad is one path per face+ let split = if self.rules.vias_allowed() { Vec::new() } else { self.split_at_pads(n, r) };+ if split.len() > 1 {+ let paths: Vec<Value> = split.iter().map(|sr| self.waypoints(n, sr)).filter(|p| p.len() >= 2).map(Value::Array).collect();+ if r.kelvin || r.pair.is_some() {+ let mut e = json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": paths});+ if r.kelvin { e["kelvin"] = json!(true); } else { e["decoupling"] = json!(r.pair.and_then(|k| self.pairs.get(k)).map(|p| p.r.label.clone())); }+ entries.push(e);+ } else if let Some(e) = by_width.iter_mut().find(|(w, _)| (*w - r.width).abs() < 1e-9) {+ e.1.extend(paths);+ } else {+ by_width.push((r.width, paths));+ }+ continue;+ } let pts = self.waypoints(n, r); if pts.len() < 2 { continue;@@ -1268,8 +1349,30 @@ impl Router { unrouted.push(json!([net.name, [k, why]])); } }- json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted, "boardCopper": self.board_copper_summary(), "decoupling": self.decoupling_report(),- "planeUnder": self.keep.iter().map(|k| json!({"label": k.label, "net": k.net, "layer": aiflow_board::LAYER_NAMES[k.layer], "polygon": k.poly})).collect::<Vec<_>>()})+ let mut rep = json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted, "boardCopper": self.board_copper_summary(), "decoupling": self.decoupling_report(),+ "planeUnder": self.keep.iter().map(|k| json!({"label": k.label, "net": k.net, "layer": aiflow_board::LAYER_NAMES[k.layer], "polygon": k.poly})).collect::<Vec<_>>()});+ if !self.rules.default_copper_mode() {+ rep["singleFace"] = self.single_face_summary();+ }+ rep+ }++ /// Issue #183: what single-face routing made of the board: connections closed on the primary face+ /// alone, connections that needed the jumper layer, layer changes made on through-hole pads, and+ /// vias (always 0: the mode plans none).+ pub fn single_face_summary(&self) -> Value {+ let primary = self.rules.primary_layer();+ let (mut closed, mut single, mut jumpered, mut changes) = (0usize, 0usize, 0usize, 0usize);+ for net in &self.nets {+ for r in &net.routes {+ closed += 1;+ if r.cells.iter().all(|t| t.layer() == primary) { single += 1; } else { jumpered += 1; }+ changes += r.cells.windows(2).filter(|w| w[0].layer() != w[1].layer()).count();+ }+ }+ json!({"layers": self.mode_layers(), "vias": false, "primary": aiflow_board::LAYER_NAMES[primary],+ "jumperLayer": self.rules.jumper_layer().map(|l| aiflow_board::LAYER_NAMES[l]),+ "connections": closed, "singleLayer": single, "withJumper": jumpered, "layerChangesAtPads": changes, "viasPlanned": self.vias.len()}) } /// What the board's own copper already did: its size, the nets it completes, the nets it@@ -1496,3 +1599,126 @@ mod path_via_tests { assert!(path_vias_clear(22,&parent,100,10,2.0*RES)); } }++/// Issue #183: single-face / no-via routing on an all through-hole strip where nets A and B must cross+/// (tests/fixtures/single-layer.kicad_pcb: J1 and J2 against the board's edges, A on J1.1, R1, J2.2 and+/// B on J1.2, J2.1, so one face alone cannot close both).+#[cfg(test)]+mod single_face_tests {+ use super::*;+ use serde_json::json;++ const STRIP: &str = include_str!("../../../tests/fixtures/single-layer.kicad_pcb");+ const BOTTOM: &str = include_str!("../../../tests/fixtures/bottom-side.kicad_pcb");++ fn strip() -> aiflow_board::Board {+ aiflow_board::Board::from_text("strip.kicad_pcb", STRIP.to_string()).unwrap()+ }++ /// Every waypoint after a path's first that names a layer: a layer change inside the path (a via).+ fn in_path_layer_changes(plan: &[Value]) -> Vec<String> {+ let mut out = Vec::new();+ for e in plan {+ for p in e["paths"].as_array().unwrap() {+ for w in p.as_array().unwrap().iter().skip(1) {+ if w.get("layer").is_some() { out.push(format!("{} {}", e["net"], p)); }+ }+ }+ }+ out+ }++ /// The layer each path is on (paths are single-layer in this mode): its first waypoint's layer, else F.Cu.+ fn path_layers(plan: &[Value]) -> Vec<String> {+ plan.iter().flat_map(|e| e["paths"].as_array().unwrap().iter().map(|p| p[0].get("layer").and_then(|l| l.as_str()).unwrap_or("F.Cu").to_string())).collect()+ }++ #[test]+ fn single_layer_only_uses_one_face_and_reports_the_crossing() {+ let rules = crate::Rules::from_spec(&json!({"routeLayers": ["B.Cu"]}));+ assert!(!rules.default_copper_mode() && !rules.vias_allowed() && rules.jumper_layer().is_none());+ let mut r = Router::new(strip(), rules, false);+ let (plan, report, failed) = route_board(&mut r, 3, 4, 40);+ assert!(failed >= 1, "A and B must cross: one face cannot close both: {}", report["unrouted"]);+ assert!(r.vias.is_empty());+ assert!(r.nets.iter().all(|n| n.routes.iter().all(|rt| rt.cells.iter().all(|t| t.layer() == B_CU))), "copper only on B.Cu");+ assert!(path_layers(&plan).iter().all(|l| l == "B.Cu"), "{plan:?}");+ assert!(in_path_layer_changes(&plan).is_empty());+ let sf = &report["singleFace"];+ assert_eq!(sf["layers"], json!(["B.Cu"]));+ assert_eq!(sf["viasPlanned"], 0);+ assert_eq!(sf["withJumper"], 0);+ assert!(sf["singleLayer"].as_u64().unwrap() >= 1, "{sf}");+ // the open connection is A or B, with its reason (the other net in the way)+ assert!(report["unrouted"].as_array().unwrap().iter().all(|u| (u[0] == "A" || u[0] == "B") && !u[1][1].as_str().unwrap().is_empty()), "{}", report["unrouted"]);+ }++ #[test]+ fn jumpers_close_the_crossing_with_layer_changes_only_on_pth_pads() {+ let rules = crate::Rules::from_spec(&json!({"vias": false}));+ assert_eq!(rules.jumper_layer(), Some(F_CU));+ let mut r = Router::new(strip(), rules, false);+ let (plan, report, failed) = route_board(&mut r, 3, 4, 40);+ assert_eq!(failed, 0, "{}", report["unrouted"]);+ assert!(r.vias.is_empty(), "no via is ever stamped: {:?}", r.vias);+ // every layer change inside a route sits on a through-hole pad of its own net+ for (n, net) in r.nets.iter().enumerate() {+ for rt in &net.routes {+ for w in rt.cells.windows(2).filter(|w| w[0].layer() != w[1].layer()) {+ let c = w[1].j as usize * r.grid.w + w[1].i as usize;+ assert!(r.own_pth_cell(net.idx as i16, c) && r.pth_cluster_at(n, w[1].i, w[1].j).is_some(), "layer change off a pad in {}", net.name);+ }+ }+ }+ // the plan has no layer change inside a path: one path per face+ assert!(in_path_layer_changes(&plan).is_empty(), "{:?}", in_path_layer_changes(&plan));+ let layers = path_layers(&plan);+ assert!(layers.iter().any(|l| l == "B.Cu") && layers.iter().any(|l| l == "F.Cu"), "B.Cu milled, F.Cu jumper: {layers:?}");+ let sf = &report["singleFace"];+ assert_eq!(sf["jumperLayer"], "F.Cu");+ assert!(sf["withJumper"].as_u64().unwrap() >= 1 && sf["singleLayer"].as_u64().unwrap() >= 1, "{sf}");+ // the jumper is the exception: most connections stay on the milled face+ assert!(sf["withJumper"].as_u64().unwrap() <= sf["singleLayer"].as_u64().unwrap(), "{sf}");+ }++ #[test]+ fn with_vias_off_a_via_is_never_possible_except_on_an_own_pth_pad() {+ let mut r = Router::new(strip(), crate::Rules::from_spec(&json!({"vias": false})), false);+ let a = r.net_ids["A"];+ let win = (0, 0, r.grid.w as i64 - 1, r.grid.h as i64 - 1);+ let (_, _, _, via_ok) = r.obstacle_maps(a, 0.25, win, false);+ let nid = r.nets[a].idx as i16;+ let mut on_pads = 0;+ for (c, ok) in via_ok.bits.iter().enumerate() {+ if *ok { assert!(r.own_pth_cell(nid, c), "via allowed off A's own pads at cell {c}"); on_pads += 1; }+ }+ assert!(on_pads > 0, "A's own through-hole pads are where it may change face");+ // and B.Cu only: nowhere, and F.Cu is a wall+ let mut r = Router::new(strip(), crate::Rules::from_spec(&json!({"routeLayers": ["B.Cu"]})), false);+ let (o, _, _, via_ok) = r.obstacle_maps(a, 0.25, win, false);+ assert!(o[F_CU].bits.iter().all(|b| *b));+ let nid = r.nets[a].idx as i16;+ assert!(via_ok.bits.iter().enumerate().all(|(c, ok)| !*ok || r.own_pth_cell(nid, c)));+ }++ #[test]+ fn a_default_spec_routes_exactly_as_before() {+ let d = crate::Rules::default();+ assert!(d.default_copper_mode() && d.vias_allowed() && d.jumper_layer().is_none());+ let both = crate::Rules::from_spec(&json!({"routeLayers": ["F.Cu", "B.Cu"], "vias": true}));+ assert!(both.default_copper_mode());+ // the rules record is unchanged for a default spec (no new keys in plan-route-report.json)+ let v = serde_json::to_value(crate::Rules::from_spec(&json!({}))).unwrap();+ assert!(v.get("route_layers").is_none() && v.get("vias").is_none(), "{v}");+ assert!(serde_json::to_value(crate::Rules::from_spec(&json!({"vias": false}))).unwrap()["vias"] == json!(false));+ for text in [BOTTOM, STRIP] {+ let mut a = Router::new(aiflow_board::Board::from_text("a.kicad_pcb", text.to_string()).unwrap(), crate::Rules::default(), false);+ let mut b = Router::new(aiflow_board::Board::from_text("a.kicad_pcb", text.to_string()).unwrap(), both.clone(), false);+ let (pa, ra, fa) = route_board(&mut a, 3, 4, 40);+ let (pb, rb, fb) = route_board(&mut b, 3, 4, 40);+ assert_eq!(serde_json::to_string(&pa).unwrap(), serde_json::to_string(&pb).unwrap());+ assert_eq!(fa, fb);+ assert!(ra.get("singleFace").is_none() && rb.get("singleFace").is_none());+ }+ }+}
crates/aiflow-router/src/lib.rs+44−1@@ -47,6 +47,10 @@ pub fn turn_cost(from: usize, to: usize, flat: bool) -> i64 { match diff.min(8 - diff) { 0 => 0, 1 => COST_TURN, _ if flat => COST_TURN, 2 => 3 * COST_TURN, 3 => 5 * COST_TURN, _ => 7 * COST_TURN } } pub const LAYER_MULT: [f64; 2] = [1.0, 1.1];+/// Issue #183, single-face boards with F.Cu jumpers: the milled face (B.Cu) is the cheaper layer, and+/// entering the jumper layer (a wire on the component side) costs as much as a 30 mm detour.+pub const JUMPER_LAYER_MULT: [f64; 2] = [1.2, 1.0];+pub const JUMPER_COST: i64 = 3000; pub const SOFT_EXTRA: i64 = 80; pub const ZONE_COST: i64 = 20; pub const VIA_WALL: i64 = 300;@@ -283,6 +287,14 @@ impl Router { keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], pairs: Vec::new(), order: variants::NetOrder::default(), board, }; r.big_clr_val = r.rules.clearance;+ // issue #183: a board with no vias has no plane to stitch down to; its plane nets are routed as+ // tracks like any other net (a pour can still be added after routing)+ if !r.rules.vias_allowed() {+ let planes: Vec<String> = r.nets.iter_mut().filter_map(|n| n.plane.take().map(|_| n.name.clone())).collect();+ let layers: Vec<&str> = (0..2).filter(|&l| r.rules.layer_allowed(l)).map(|l| aiflow_board::LAYER_NAMES[l]).collect();+ r.log(format!("router: single-face mode: layers {}, no vias (a layer change only at a through-hole pad of the same net){}", layers.join(" + "),+ if planes.is_empty() { String::new() } else { format!("; plane net(s) {} routed as tracks (no via can reach a plane)", planes.join(", ")) }));+ } let t0 = std::time::Instant::now(); r.set_aside_outside_pads(); r.stamp_pads();@@ -465,8 +477,10 @@ impl Router { } k = m; }+ // issue #183: with vias off a layer change is a through-hole pad of the net, never a via+ let vias = self.rules.vias_allowed(); for k in 1..cells.len() {- if cells[k].layer() != cells[k - 1].layer() && (cells[k].i, cells[k].j) == (cells[k - 1].i, cells[k - 1].j) {+ if vias && cells[k].layer() != cells[k - 1].layer() && (cells[k].i, cells[k].j) == (cells[k - 1].i, cells[k - 1].j) { self.stamp_via(cells[k].i, cells[k].j, net, sign); } }@@ -999,9 +1013,38 @@ impl Router { *b = false; } }+ // issue #183: a layer the spec (or the project's .kicad_dru) leaves out is a wall, and with vias+ // off the only layer change is on a through-hole pad of this net (the lead joins both faces)+ if !self.rules.default_copper_mode() {+ for l in 0..2 {+ if !self.rules.layer_allowed(l) {+ for b in obs_o[l].bits.iter_mut() { *b = true; }+ }+ }+ if !self.rules.vias_allowed() {+ let (ww, hw) = ((i1 - i0 + 1) as usize, (j1 - j0 + 1) as usize);+ for jj in 0..hw {+ for ii in 0..ww {+ let c = (j0 as usize + jj) * g.w + i0 as usize + ii;+ via_ok.bits[jj * ww + ii] = self.own_pth_cell(nid, c);+ }+ }+ }+ } (obs_o, obs_d, softm, via_ok) } + /// Issue #183: is grid cell `c` a through-hole pad of net id `nid` (copper of that net on both layers)?+ pub fn own_pth_cell(&self, nid: i16, c: usize) -> bool {+ self.thru_cells.bits[c] && self.pad_raw[F_CU][c] == nid && self.pad_raw[B_CU][c] == nid+ }++ /// Issue #183: the cluster of this net whose through-hole pad covers cell (i, j), if any.+ pub fn pth_cluster_at(&self, net: usize, i: i32, j: i32) -> Option<usize> {+ let c = j as usize * self.grid.w + i as usize;+ self.nets[net].clusters.iter().position(|cl| cl.pads.iter().any(|p| self.board.pads[*p].thru && self.pad_cells.get(p).is_some_and(|cells| cells.contains(&c))))+ }+ /// The width of a net's escape stubs: the rule stub, or an impedance-controlled net's own width /// when every one of its pads is at least that wide (so a pair leaves its pads already at the /// solved width and gap; a narrower pad keeps the rule stub and its breakout stays uncontrolled).
crates/aiflow-router/src/rules.rs+49@@ -37,8 +37,19 @@ pub struct Rules { /// net is routed on (no vias), and for a pair its partner and centre pitch. Filled by `route` /// from impedance::table, never from the spec directly. pub impedance: BTreeMap<String, ImpedanceRoute>,+ /// Issue #183: the copper layers the router may use (spec "routeLayers", or the project's+ /// `.kicad_dru` `disallow track` on a layer). Empty means both outer layers (the default).+ #[serde(skip_serializing_if = "Vec::is_empty")]+ pub route_layers: Vec<String>,+ /// Issue #183: false forbids every via (spec "vias": false, or the project's `.kicad_dru`+ /// `(constraint disallow via)`). A layer change is then made only at a through-hole pad of the+ /// same net, where the component lead joins both faces.+ #[serde(skip_serializing_if = "is_true")]+ pub vias: bool, } +fn is_true(b: &bool) -> bool { *b }+ /// How the router routes one impedance-controlled net. #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default)]@@ -69,6 +80,7 @@ impl Default for Rules { track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5, stub: 0.25, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5, planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(), decoupling: Vec::new(), impedance: BTreeMap::new(),+ route_layers: Vec::new(), vias: true, } } }@@ -114,8 +126,45 @@ impl Rules { if let Some(v) = num("viaPower") { r.via = v; } if let Some((v, d)) = pair("viaPower") { r.via = v; if let Some(d) = d { r.drill = d; } } if let Some(v) = num("drillPower") { r.drill = v; }+ // issue #183: single-face / no-via boards (main.rs also reads the project's .kicad_dru)+ r.route_layers = spec_route_layers(spec).unwrap_or_default();+ if let Some(v) = spec.get("vias").and_then(|v| v.as_bool()) { r.vias = v; } r }++ /// May the router put copper on this layer (F_CU or B_CU)?+ pub fn layer_allowed(&self, l: usize) -> bool {+ self.route_layers.is_empty() || self.route_layers.iter().any(|n| n == aiflow_board::LAYER_NAMES[l])+ }++ /// Both outer layers and vias: today's two-layer router.+ pub fn default_copper_mode(&self) -> bool {+ self.vias && self.layer_allowed(aiflow_board::F_CU) && self.layer_allowed(aiflow_board::B_CU)+ }++ /// May the router change layer anywhere (a via)? Never with one layer, never with vias off.+ pub fn vias_allowed(&self) -> bool {+ self.default_copper_mode()+ }++ /// With both layers and no vias, F.Cu copper is a wire jumper on the component side and B.Cu is+ /// the milled (primary) face: the layer the router charges extra to enter.+ pub fn jumper_layer(&self) -> Option<usize> {+ (!self.vias && self.layer_allowed(aiflow_board::F_CU) && self.layer_allowed(aiflow_board::B_CU)).then_some(aiflow_board::F_CU)+ }++ /// The face a single-face board is routed on: the one allowed layer, or B.Cu when F.Cu is jumpers.+ pub fn primary_layer(&self) -> usize {+ if !self.layer_allowed(aiflow_board::B_CU) { aiflow_board::F_CU } else { aiflow_board::B_CU }+ }+}++/// The spec's "routeLayers" as layer names the router knows (F.Cu, B.Cu), in board order; None when+/// the spec has no such key or names no layer the router knows.+pub fn spec_route_layers(spec: &serde_json::Value) -> Option<Vec<String>> {+ let a = spec.get("routeLayers")?.as_array()?;+ let names: Vec<String> = ["F.Cu", "B.Cu"].iter().filter(|l| a.iter().any(|v| v.as_str() == Some(**l))).map(|l| l.to_string()).collect();+ if names.is_empty() { None } else if names.len() == 2 { Some(Vec::new()) } else { Some(names) } } impl PlaneUnder {
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.58",+ "version": "0.2.59", "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.58",+ "version": "0.2.59", "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+1@@ -45,6 +45,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 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. Every open connection is counted, also a net the router never got back to after a rip-up (reason "never routed"), so the summary and the variant ranking see it. **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged.+ **Single-face / no-via boards** (issue #183, a CNC-milled one-layer board of through-hole parts): spec `"routeLayers": ["B.Cu"]` (or `["F.Cu"]`, or both) limits the router's layers, and `"vias": false` forbids every via: a layer change is made only on a through-hole pad of the same net, where the lead joins both faces. With both layers and `"vias": false`, F.Cu copper is wire jumpers on the component side (the router takes one only when B.Cu has no way through). Both keys are optional; when the spec is silent, the project's `.kicad_dru` decides (`(constraint disallow via)` turns vias off, `disallow track` on one layer leaves the other) and a NOTE says so. The rules-used line shows the layers and vias; `route` says how many connections closed on the single face and lists every jumper (`singleFace` on the route outcome and in `plan-route-report.json`, `jumpers` in route quality). Plane nets are routed as tracks, and pours, `thermal-vias` and stitch vias plan no via. Paths are cut on the pad (one path per face), so `gate` and `land route` see no via; a hand-written plan with a via, or copper on a face the board does not route, is a gate finding and `land route` lands nothing. Connections that cannot close are findings with suggestions (allow F.Cu jumpers, move or rotate a part, widen the channel), never a refusal. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Pours (and their vias) that `pour` planned and that are not landed yet go into the gate copy, so a net only the planned pours join is not read as unconnected (`plannedPours` in gate.json). 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. A loaded net with filled zones and no tracks at all is "carried only by its pour" (judged on the saved board by whether the pour reaches its terminals, then by the Fields solve), never no-copper. 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.
tests/fixtures/single-layer.kicad_pcbadded+124@@ -0,0 +1,124 @@+(kicad_pcb+ (version 20260206)+ (generator "aiflow-test")+ (layers+ (0 "F.Cu" signal)+ (2 "B.Cu" signal)+ (1 "F.Mask" user)+ (3 "B.Mask" user)+ (5 "F.SilkS" user)+ (7 "B.SilkS" user)+ (25 "Edge.Cuts" user)+ (31 "F.CrtYd" user)+ (29 "B.CrtYd" user)+ )+ (footprint "T:J1"+ (layer "F.Cu")+ (at 1.8 1.73)+ (property "Reference" "J1"+ (at 0 0 0)+ (layer "F.SilkS")+ (hide yes)+ )+ (fp_rect+ (start -1.1 -1.1)+ (end 1.1 3.64)+ (stroke+ (width 0.05)+ (type solid)+ )+ (fill no)+ (layer "F.CrtYd")+ )+ (pad "1" thru_hole circle+ (at 0 0)+ (size 2 2)+ (drill 1)+ (layers "*.Cu" "*.Mask")+ (net "A")+ )+ (pad "2" thru_hole circle+ (at 0 2.54)+ (size 2 2)+ (drill 1)+ (layers "*.Cu" "*.Mask")+ (net "B")+ )+ )+ (footprint "T:R1"+ (layer "F.Cu")+ (at 12 3)+ (property "Reference" "R1"+ (at 0 0 0)+ (layer "F.SilkS")+ (hide yes)+ )+ (fp_rect+ (start -1.1 -1.1)+ (end 7.1 1.1)+ (stroke+ (width 0.05)+ (type solid)+ )+ (fill no)+ (layer "F.CrtYd")+ )+ (pad "1" thru_hole circle+ (at 0 0)+ (size 2 2)+ (drill 1)+ (layers "*.Cu" "*.Mask")+ (net "A")+ )+ (pad "2" thru_hole circle+ (at 6 0)+ (size 2 2)+ (drill 1)+ (layers "*.Cu" "*.Mask")+ (net "A")+ )+ )+ (footprint "T:J2"+ (layer "F.Cu")+ (at 28.2 1.73)+ (property "Reference" "J2"+ (at 0 0 0)+ (layer "F.SilkS")+ (hide yes)+ )+ (fp_rect+ (start -1.1 -1.1)+ (end 1.1 3.64)+ (stroke+ (width 0.05)+ (type solid)+ )+ (fill no)+ (layer "F.CrtYd")+ )+ (pad "1" thru_hole circle+ (at 0 0)+ (size 2 2)+ (drill 1)+ (layers "*.Cu" "*.Mask")+ (net "B")+ )+ (pad "2" thru_hole circle+ (at 0 2.54)+ (size 2 2)+ (drill 1)+ (layers "*.Cu" "*.Mask")+ (net "A")+ )+ )+ (gr_rect+ (start 0 0)+ (end 30 6)+ (stroke+ (width 0.05)+ (type solid)+ )+ (fill no)+ (layer "Edge.Cuts")+ )+)
tests/single-layer-workflow.pyadded+167@@ -0,0 +1,167 @@+"""Issue #183 end to end on tests/fixtures/single-layer.kicad_pcb: a CNC-milled one-face board of+through-hole parts. J1 and J2 sit against the board's edges and nets A (J1.1, R1, J2.2) and B (J1.2,+J2.1) must cross, so one face alone cannot close both.++- spec "routeLayers": ["B.Cu"]: route plans B.Cu copper only and no via, the rules-used line says so,+ and the crossing comes back as a finding with suggestions (allow F.Cu jumpers, move a part, widen the+ channel), never a refusal;+- no spec keys, the project's .kicad_dru with (constraint disallow via) and disallow track on F.Cu: the+ same mode, with a note naming the .kicad_dru;+- "routeLayers": ["B.Cu", "F.Cu"], "vias": false: route closes everything, F.Cu copper is wire jumpers+ that change face only on through-hole pads, the report lists them, gate passes and land route sends+ the bridge paths with no layer change inside them (no via);+- a hand-written plan with a via is a gate finding and land route lands nothing; a hand-written layer+ change on a through-hole pad is cut there without a via;+- pour plans no stitching via on this board.++A fake kicad-cli answers DRC with no violations and a fake adom-bridge records every call, so this runs+with no CAD and no desktop. Usage: python3 tests/single-layer-workflow.py <adom-aiflow binary>"""+import json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())+fixture = pathlib.Path(__file__).resolve().parent / 'fixtures' / 'single-layer.kicad_pcb'++FAKE_BRIDGE = r'''#!/usr/bin/env python3+import json, os, sys+a = sys.argv[1:]+i = a.index("--target")+verb, args = a[i + 2], json.loads(a[i + 3])+with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"verb": verb, "args": args}) + "\n")+print(json.dumps({"success": True, "status": "ok", "revision": "r1"}))+'''+FAKE_CLI = ('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'+ 'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'+ 'out = a[a.index("--output") + 1]\njson.dump({"violations": [], "unconnected_items": []}, open(out, "w"))\n')+DRU = '(version 1)\n# milled on phenolic: one copper face, no plated holes\n(rule "no vias"\n\t(constraint disallow via))\n(rule "no top copper"\n\t(layer F.Cu)\n\t(constraint disallow track zone))\n'++passed = 0+++def check(cond, what, out=''):+ global passed+ if not cond:+ sys.exit(f'FAIL: {what}\n{out}')+ passed += 1+++def in_path_changes(entries):+ """Waypoints after a path's first that name a layer: a layer change (a via) inside the path."""+ return [(e['net'], p) for e in entries for p in e.get('paths', [e.get('points')]) if any(isinstance(w, dict) and 'layer' in w for w in p[1:])]+++def path_layer(e, p):+ w = p[0]+ return e.get('layer') or (w.get('layer') if isinstance(w, dict) else None) or 'F.Cu'+++with tempfile.TemporaryDirectory(prefix='aiflow-single-') as td:+ p = pathlib.Path(td)+ bindir = p / 'bin'; bindir.mkdir()+ (bindir / 'adom-bridge').write_text(FAKE_BRIDGE); (bindir / 'adom-bridge').chmod(0o755)+ (bindir / 'kicad-cli').write_text(FAKE_CLI); (bindir / 'kicad-cli').chmod(0o755)+ log = p / 'bridge.jsonl'+ env = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1',+ ADOM_AIFLOW_KICAD_CLI=str(bindir / 'kicad-cli'), FAKE_LOG=str(log), PATH=f"{bindir}:{os.environ['PATH']}")+ env.pop('KICAD_REMOTE_TARGET', None)++ def call(run, *args):+ return subprocess.run([binary, '--run', str(run), '--ai-thread', 'single-test', *args], env=env, capture_output=True, text=True, timeout=300)++ def start(name, spec, dru=None):+ d = p / name; d.mkdir()+ (d / 'b.kicad_pcb').write_text(fixture.read_text())+ if dru: (d / 'b.kicad_dru').write_text(dru)+ (d / 'spec.json').write_text(json.dumps(spec))+ run = d / 'run'+ r = call(run, 'start', '--board', str(d / 'b.kicad_pcb'), '--spec', str(d / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', f'C:/boards/{name}.kicad_pcb')+ check(r.returncode == 0, f'start {name}', r.stdout + r.stderr)+ return run++ def outcome(run, k):+ return json.loads((run / 'run.json').read_text())['outcomes'][k]++ # ---- 1. B.Cu only (spec): one face, no via, the crossing is a finding with suggestions+ run = start('bonly', {"board": "single-layer fixture", "clearance": 0.2, "routeLayers": ["B.Cu"]})+ r = call(run, 'route', '--passes', '3')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'ERROR' not in out, 'route on one face never refuses', out)+ check('route rules used:' in out and 'layers B.Cu (spec), vias off' in out, 'rules-used line shows the layers and vias', out)+ check('FINDINGS' in out and 'Allow wire jumpers' in out and 'Move or rotate a part' in out and 'Widen the channel' in out, 'the crossing is a finding with the three suggestions', out)+ o = outcome(run, 'route')+ check(o['unrouted'] >= 1, 'A and B cannot both close on one face', json.dumps(o)[:600])+ sf = o.get('singleFace') or {}+ check(sf.get('layers') == ['B.Cu'] and sf.get('viasPlanned') == 0 and sf.get('withJumper') == 0 and sf.get('singleLayer', 0) >= 1, 'singleFace counts', json.dumps(sf))+ check('closed on B.Cu alone' in out, 'route says how many closed on the single layer', out)+ plan = json.loads((run / 'plan-route.json').read_text())+ check(plan.get('vias') is False and plan.get('routeLayers') == ['B.Cu'], 'the plan says its mode', json.dumps(plan)[:300])+ check(not in_path_changes(plan['nets']), 'no layer change inside a path', json.dumps(in_path_changes(plan['nets'])))+ check(all(path_layer(e, pp) == 'B.Cu' for e in plan['nets'] for pp in e['paths']), 'every path on B.Cu', json.dumps(plan['nets']))++ # ---- 2. the project's .kicad_dru decides when the spec is silent (a note, not a refusal)+ run = start('dru', {"board": "single-layer fixture", "clearance": 0.2}, dru=DRU)+ r = call(run, 'route', '--passes', '3')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'ERROR' not in out, 'route with the dru never refuses', out)+ check('NOTE' in out and '(constraint disallow via)' in out and 'disallows tracks on F.Cu' in out, 'notes name the .kicad_dru rules applied', out)+ check('layers B.Cu (project .kicad_dru), vias off (project .kicad_dru)' in out, 'rules-used line says the dru decided', out)+ plan = json.loads((run / 'plan-route.json').read_text())+ check(not in_path_changes(plan['nets']) and all(path_layer(e, pp) == 'B.Cu' for e in plan['nets'] for pp in e['paths']), 'dru mode: B.Cu only, no via', json.dumps(plan['nets']))++ # ---- 3. B.Cu with F.Cu wire jumpers: everything closes, jumpers listed, gate and land route+ spec3 = {"board": "single-layer fixture", "clearance": 0.2, "routeLayers": ["B.Cu", "F.Cu"], "vias": False,+ "pours": [{"net": "A", "layers": ["B.Cu"], "outline": True}], "stitchVias": [{"net": "A", "x": 10, "y": 3}]}+ run = start('jumpers', spec3)+ r = call(run, 'route', '--passes', '3')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'OK: routing closed: 0 unrouted' in out, 'jumpers close the crossing', out)+ check('layers F.Cu + B.Cu (spec), vias off (spec): a layer change only on a through-hole pad' in out and 'wire jumpers' in out, 'rules-used line names the jumper mode', out)+ check('need a jumper' in out and ' jumper ' in out, 'route lists the jumpers', out)+ o = outcome(run, 'route')+ sf = o['singleFace']+ check(sf['jumpers']['count'] >= 1 and sf['withJumper'] >= 1 and sf['singleLayer'] >= 1 and sf['viasPlanned'] == 0 and sf['planIssues'] == [], 'singleFace record with jumpers', json.dumps(sf))+ check(all(j['from'] and j['to'] for j in sf['jumpers']['list']), 'each jumper names its ends', json.dumps(sf['jumpers']))+ check(o['routeQuality'].get('jumpers') == sf['jumpers']['count'], 'route quality counts the jumpers', json.dumps(o['routeQuality']))+ plan = json.loads((run / 'plan-route.json').read_text())+ check(not in_path_changes(plan['nets']), 'jumpers: no layer change inside a path', json.dumps(in_path_changes(plan['nets'])))+ layers = {path_layer(e, pp) for e in plan['nets'] for pp in e['paths']}+ check(layers == {'B.Cu', 'F.Cu'}, 'milled B.Cu and jumper F.Cu paths', json.dumps(plan['nets']))+ rep = json.loads((run / 'plan-route-report.json').read_text())+ check(rep['singleFace']['jumpers']['count'] == sf['jumpers']['count'] and rep['rules'].get('vias') is False, 'report carries the mode and jumpers', json.dumps(rep['singleFace']))+ r = call(run, 'gate')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'gate passed' in out, 'gate passes the jumper plan', out)+ g = json.loads((run / 'gate.json').read_text())+ check(g['singleFace']['issues'] == [] and g['singleFace']['jumpers']['count'] >= 1 and g['copper'].get('vias') in (0, None), 'gate: no single-face issue, jumpers, no via', json.dumps(g.get('singleFace')) + json.dumps(g.get('copper')))+ check(outcome(run, 'gate').get('singleFaceIssues') == 0, 'gate outcome records the single-face issues')+ r = call(run, 'land', 'route', '--pause-ms', '0')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'FINDINGS' not in out, 'land route', out)+ sent = [json.loads(l) for l in log.read_text().splitlines() if l.strip()]+ routes = [s['args'] for s in sent if s['verb'] == 'kicad_route_net']+ check(routes, 'kicad_route_net sent', json.dumps(sent)[:800])+ check(not in_path_changes(routes), 'landed paths change no layer inside a path (no via)', json.dumps(in_path_changes(routes)))+ check(not any('via' in s['verb'] for s in sent), 'no via verb sent', json.dumps([s['verb'] for s in sent]))+ # pours: no stitching via on a board with no vias+ r = call(run, 'pour')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and ', 0 vias' in out and 'stitchVias entry is left out' in out, 'pour plans no via', out)+ check(json.loads((run / 'plan-vias.json').read_text())['nets'] == [], 'plan-vias.json is empty')++ # ---- 4. hand-written plans: a via is a finding (gate, land), a layer change on a PTH pad is cut there+ with_via = {"nets": [{"net": "B", "width": 0.25, "paths": [[{"pad": "J1.2", "layer": "B.Cu"}, [6.0, 4.27], {"x": 6.0, "y": 4.27, "layer": "F.Cu"}, [24.0, 1.73], "J2.1"]]}]}+ (p / 'via-plan.json').write_text(json.dumps(with_via))+ r = call(run, 'gate', '--plan', str(p / 'via-plan.json'))+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'FINDINGS' in out and 'a via at (6.00, 4.27)' in out and 'ERROR' not in out, 'gate names the via as a finding', out)+ before = len(log.read_text().splitlines())+ r = call(run, 'land', 'route', '--plan', str(p / 'via-plan.json'), '--pause-ms', '0')+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'FINDINGS' in out and 'nothing landed' in out, 'land route lands no via', out)+ check(len(log.read_text().splitlines()) == before, 'no bridge call for the via plan')+ at_pad = {"nets": [{"net": "A", "width": 0.25, "paths": [[{"pad": "J1.1", "layer": "B.Cu"}, [12.0, 1.73], {"pad": "R1.1", "layer": "F.Cu"}, [15.0, 3.0], "R1.2"]]}]}+ (p / 'pad-plan.json').write_text(json.dumps(at_pad))+ r = call(run, 'gate', '--plan', str(p / 'pad-plan.json'))+ out = r.stdout + r.stderr+ check(r.returncode == 0 and 'made without a via' in out and 'a via at' not in out, 'a layer change on a PTH pad is cut there, no via', out)++print(f'OK single-layer-workflow: {passed} checks passed')