From 8ddf5db7f9b537fbf95a780159f60ef7b980abfc Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 23:48:12 -0400 Subject: [PATCH 1/3] feat(router): synthesize copper pours for high-current nets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router synthesized traces only. copper_pour::fill_zones could fill a zone that already existed, but nothing ever created a zone *definition* — so a board whose power nets carry tens of amps got hairline copper where a human designer floods a region. The CM5 fixture hid this behind 121 hand-authored zones; the moteus fixture (Eagle, whose polygons the importer drops) exposes it. New `vcad_ecad_pcb::pour_synth`, split so the judgement is inspectable separately from the drawing: * Policy (`PourPolicy` / `decide_pours`) — which nets, which layers, and why. A declared current is sized through the *same* IPC-2221 closed form the `size_trace_for_current` MCP tool evaluates; a wide net class and a power-rail name are the fallbacks for imported boards, which carry no current annotation. Pad count *or* pad copper qualifies a net, since a battery input is two big terminals rather than twenty small ones. A layer is chosen to minimise stitching: pour where the net already lives, and never flood a layer fewer than half its pads sit on. * Geometry (`synthesize_outlines`) — region floods, promoted to a whole layer once a net spans the board, clipped to the outline eroded by edge clearance. A whole-layer flood owns its layer; local pours share one, smallest-claims-first. One plane per candidate, and if any pad cannot reach it the candidate is dropped and that net keeps its traces: the router treats a poured net as connected *through* its plane, so a pour that missed a pad would silently strand it. Zones come back in `RouteAllResult::zones` and every caller (cm5_bench, the WASM binding, `route_nets`) applies them — the routing assumes them. Three legality fixes the pours surfaced, each of which stands on its own: * Escape/stitch vias are now checked against the board's drilled holes when they are placed, instead of being committed and cleaned up afterwards. * Post-route legalization demoted a whole net on one bad drill. That is right for a routed path and catastrophic for a plane-stitched net, whose copper is dozens of independent pad->plane vias: on moteus a single hole conflict cost all 32 GND stitches and left the pour connected to nothing. A stitch via is now dropped on its own, with its dog-bone stub. * `fill_zones` now prunes orphan islands — fill fragments that reach none of their own net's copper. Floating metal carries no current and is a `NetIslands` error; the largest piece is always kept so a zone poured before its net is routed still renders. `is_power_net` folds away word separators, so `V_SUPPLY` reads as the rail it is. Measured on this base (VCAD_POUR_SYNTH=0 is the A/B control): moteus routability 0.872 -> 0.891, vias 256 -> 140 (0.73x -> 0.40x human), copper 1.19x -> 0.78x human, Clearance 100 -> 76, Short 177 -> 121, SameNetBypass 3 -> 0, NetIslands 0 -> 0, UnstitchedPad 0 -> 0; 445 -> 359 violations overall. GND floods F.Cu as one plane, reached by 3 stitching vias. VESC bit-identical with synthesis on and off (0.794, 223 violations). Its 13 hand-authored zones already cover ~80% of both layers, so the geometry correctly drops the one candidate the policy picks. CM5 (top 150 nets) flat: routability 0.982 -> 0.981, 2912 -> 2927 violations. Synthesis adds one 49mm^2 zone for a rail that owned none and duplicates nothing. On moteus the pour's own copper is provably legal: the Clearance count is identical with and without the zone (76 both), so it touches no foreign copper. It does raise Short against the same routed board with zones stripped (97 -> 121) — that is the plane unifying GND's copper, so nets already shorted to GND get reported pairwise. Against the real control the net effect is still strongly positive (177 -> 121). Co-Authored-By: Claude Opus 5 --- ...26-07-24-router-copper-pour-synthesis.json | 10 + crates/vcad-ecad-pcb/examples/cm5_bench.rs | 30 + crates/vcad-ecad-pcb/examples/pour_report.rs | 128 ++ crates/vcad-ecad-pcb/src/copper_pour.rs | 139 +- crates/vcad-ecad-pcb/src/drc.rs | 20 +- crates/vcad-ecad-pcb/src/lib.rs | 6 + crates/vcad-ecad-pcb/src/pour_synth.rs | 1522 +++++++++++++++++ crates/vcad-ecad-pcb/src/router/auto.rs | 171 +- crates/vcad-ecad-pcb/src/router/legalize.rs | 112 +- crates/vcad-kernel-wasm/src/lib.rs | 14 +- packages/engine/src/ecad.ts | 9 + packages/mcp/src/tools/ecad.ts | 12 + 12 files changed, 2136 insertions(+), 37 deletions(-) create mode 100644 changelog/entries/2026-07-24-router-copper-pour-synthesis.json create mode 100644 crates/vcad-ecad-pcb/examples/pour_report.rs create mode 100644 crates/vcad-ecad-pcb/src/pour_synth.rs diff --git a/changelog/entries/2026-07-24-router-copper-pour-synthesis.json b/changelog/entries/2026-07-24-router-copper-pour-synthesis.json new file mode 100644 index 000000000..54dfdf245 --- /dev/null +++ b/changelog/entries/2026-07-24-router-copper-pour-synthesis.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-24-router-copper-pour-synthesis", + "version": "0.9.4", + "date": "2026-07-24", + "category": "feat", + "title": "Autorouter pours planes for high-current nets", + "summary": "route_nets now synthesizes copper pours for power nets instead of routing them as thin traces: it sizes current with the same IPC-2221 model as size_trace_for_current, floods a layer, and stitches every pad to the plane.", + "features": ["pcb", "autorouter", "copper-pour", "power-integrity"], + "mcpTools": ["route_nets", "size_trace_for_current", "run_drc", "validate_for_fab"] +} diff --git a/crates/vcad-ecad-pcb/examples/cm5_bench.rs b/crates/vcad-ecad-pcb/examples/cm5_bench.rs index fa213433d..61789c864 100644 --- a/crates/vcad-ecad-pcb/examples/cm5_bench.rs +++ b/crates/vcad-ecad-pcb/examples/cm5_bench.rs @@ -144,6 +144,13 @@ fn main() { ); let width = pcb.rules.default_rules.trace_width; + // VCAD_POUR_SYNTH=0 routes the board exactly as authored — the A/B control + // for what copper-pour synthesis is worth on a given fixture. + let mut pour_policy = vcad_ecad_pcb::pour_synth::PourPolicy::default(); + if std::env::var("VCAD_POUR_SYNTH").is_ok_and(|v| v == "0") { + pour_policy.enabled = false; + println!("pours: synthesis DISABLED (VCAD_POUR_SYNTH=0)"); + } let t0 = Instant::now(); let r = route_all_with_opts( &pcb, @@ -152,6 +159,7 @@ fn main() { &RouteOptions { effort, priority_nets: priority.clone(), + pour_policy, ..Default::default() }, ); @@ -167,6 +175,22 @@ fn main() { r.routed_nets.len(), r.unrouted_nets.len(), ); + if !r.zones.is_empty() { + let mut by_net: BTreeMap<&str, usize> = BTreeMap::new(); + for z in &r.zones { + *by_net.entry(z.net.as_str()).or_default() += 1; + } + println!( + "pours: {} synthesized zone(s) over {} net(s): {}", + r.zones.len(), + by_net.len(), + by_net + .iter() + .map(|(n, c)| format!("{n}x{c}")) + .collect::>() + .join(", ") + ); + } println!( "score: routability {:.3}, via ratio {:.2}x human, length ratio {:.2}x human, {:.1}s", r.routability, @@ -191,6 +215,12 @@ fn main() { // oracle-gated, so this can only improve the pair claims — and it runs // here, inside the route, so a *freshly routed* board is the one the // receipt is measured on. + // + // Synthesized pours go on first: the routing above assumes them (a poured + // net is carried by its plane, so its pads were stitched rather than traced + // to each other), and the SI pass reroutes against this board, so it has to + // see the planes too. + pcb.zones.extend(r.zones.iter().cloned()); for t in &r.traces { pcb.traces.push(Trace { start: t.start, diff --git a/crates/vcad-ecad-pcb/examples/pour_report.rs b/crates/vcad-ecad-pcb/examples/pour_report.rs new file mode 100644 index 000000000..92ad44137 --- /dev/null +++ b/crates/vcad-ecad-pcb/examples/pour_report.rs @@ -0,0 +1,128 @@ +//! Inspect copper-pour synthesis on a board without routing it: which nets the +//! policy selects, why, on which layers, and how much copper the outlines cover. +//! +//! The policy half of [`vcad_ecad_pcb::pour_synth`] is deliberately separable +//! from the geometry so it can be argued with; this is the window onto it. +//! +//! ```bash +//! cargo run --release -p vcad-ecad-pcb --example pour_report -- board.kicad_pcb +//! ``` + +use std::collections::BTreeMap; + +use vcad_ecad_pcb::pour_synth::{decide_pours, synthesize_outlines, PourPolicy, PourReason}; +use vcad_ir::ecad::Pcb; + +fn main() { + let path = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("usage: pour_report "); + std::process::exit(2); + }); + let text = std::fs::read_to_string(&path).expect("read board file"); + let pcb: Pcb = if path.ends_with(".json") { + serde_json::from_str(&text).expect("parse pcb json") + } else if path.ends_with(".brd") { + vcad_ecad_symbols::parse_eagle_brd(&text).expect("parse eagle brd") + } else { + vcad_ecad_symbols::parse_kicad_pcb(&text).expect("parse kicad_pcb") + }; + + let mut existing: BTreeMap<&str, usize> = BTreeMap::new(); + for z in &pcb.zones { + if z.layer.is_copper() && !z.net.is_empty() { + *existing.entry(z.net.as_str()).or_default() += 1; + } + } + println!( + "board: {} copper layers, {} footprints, {} existing copper zones over {} net(s)", + pcb.stackup + .layers + .iter() + .filter(|l| l.layer.is_copper()) + .count(), + pcb.footprints.len(), + pcb.zones.iter().filter(|z| z.layer.is_copper()).count(), + existing.len(), + ); + for (net, n) in &existing { + println!(" existing: {net} ({n} zone(s))"); + } + + let usable = vcad_ecad_pcb::pour_synth::usable_area(&pcb); + println!( + "outline: {} vertices, {} cutouts; usable area {:.0} mm^2 in {} region(s)", + pcb.outline.vertices.len(), + pcb.outline.cutouts.len(), + usable.iter().map(|p| p.area()).sum::(), + usable.len(), + ); + + let policy = PourPolicy::default(); + let candidates = decide_pours(&pcb, &policy, &[]); + println!( + "\npolicy: {} candidate (net, layer) pour(s)", + candidates.len() + ); + for c in &candidates { + let why = match &c.reason { + PourReason::DeclaredCurrent { + current_a, + required_width_mm, + routed_width_mm, + } => format!( + "declared {current_a:.1} A needs {required_width_mm:.2} mm, \ + routed at {routed_width_mm:.2} mm" + ), + PourReason::ClassWidth { + class_width_mm, + default_width_mm, + implied_current_a, + } => format!( + "class width {class_width_mm:.2} mm vs default {default_width_mm:.2} mm \ + (~{implied_current_a:.1} A)" + ), + PourReason::PowerRailName { + pads, + traced_current_a, + } => format!("power-rail name, {pads} pads (a trace carries ~{traced_current_a:.1} A)"), + }; + println!( + " {} on {:?} ({} pad(s) on layer) — {why}", + c.net, c.layer, c.pads_on_layer + ); + } + + let zones = synthesize_outlines(&pcb, &candidates, &policy); + println!("\ngeometry: {} synthesized zone(s)", zones.len()); + let mut by_net: BTreeMap<(&str, String), (usize, f64)> = BTreeMap::new(); + for z in &zones { + let e = by_net + .entry((z.net.as_str(), format!("{:?}", z.layer))) + .or_default(); + e.0 += 1; + e.1 += ring_area(&z.outline); + } + for ((net, layer), (n, area)) in &by_net { + println!(" {net} on {layer}: {n} piece(s), {area:.0} mm^2"); + } + let dropped: Vec<&str> = candidates + .iter() + .filter(|c| !zones.iter().any(|z| z.net == c.net && z.layer == c.layer)) + .map(|c| c.net.as_str()) + .collect(); + if !dropped.is_empty() { + println!(" dropped (region could not cover every pad): {dropped:?}"); + } +} + +/// Absolute shoelace area of a closed ring. +fn ring_area(ring: &[vcad_ir::Vec2]) -> f64 { + let n = ring.len(); + let mut s = 0.0; + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + s += a.x * b.y - b.x * a.y; + } + (0.5 * s).abs() +} diff --git a/crates/vcad-ecad-pcb/src/copper_pour.rs b/crates/vcad-ecad-pcb/src/copper_pour.rs index 98eed283d..2ad8ce99f 100644 --- a/crates/vcad-ecad-pcb/src/copper_pour.rs +++ b/crates/vcad-ecad-pcb/src/copper_pour.rs @@ -81,12 +81,32 @@ fn fill_zone(pcb: &Pcb, zone: &Zone) -> FilledZone { poly2d::difference(&[subject], &clips) }; + // Orphan removal: a fill can be cut by other-net copper into pieces, and a + // piece that reaches none of its own net's copper is floating metal — it + // carries no current, it is a DRC `NetIslands` error, and every EDA tool + // drops it before plotting. Keep the largest piece unconditionally, so a + // zone poured before its net has been routed or stitched still renders as + // the plane the designer drew. + let anchors = same_net_anchors(pcb, zone); + let biggest = filled + .iter() + .enumerate() + .max_by(|a, b| a.1.area().total_cmp(&b.1.area())) + .map(|(i, _)| i); + // Emit outer + hole rings, dropping copper islands below the minimum area. let mut polygons = Vec::new(); - for poly in &filled { + for (i, poly) in filled.iter().enumerate() { if zone.min_area > 0.0 && poly.area() < zone.min_area { continue; } + if Some(i) != biggest + && !anchors + .iter() + .any(|&a| poly2d::contains_point(poly, Point2::new(a.x, a.y))) + { + continue; + } polygons.push(pts_to_ring(&poly.outer)); for hole in &poly.holes { polygons.push(pts_to_ring(hole)); @@ -100,6 +120,44 @@ fn fill_zone(pcb: &Pcb, zone: &Zone) -> FilledZone { } } +/// Points where the zone's own net has copper that the pour can flood into: its +/// pads on this layer, its vias (a via floods every layer it spans), and its +/// traces on this layer — sampled along their length, since a trace can cross an +/// island without either endpoint being inside it. +/// +/// A filled island containing none of these reaches nothing. +fn same_net_anchors(pcb: &Pcb, zone: &Zone) -> Vec { + /// Spacing (mm) at which a same-net trace is sampled for anchor points. + const TRACE_SAMPLE_MM: f64 = 1.0; + + let mut pts = Vec::new(); + for footprint in &pcb.footprints { + for pad in &footprint.pads { + if pad.net.as_deref() == Some(zone.net.as_str()) && pad.layers.contains(&zone.layer) { + pts.push(crate::geometry::pad_world_position(footprint, pad)); + } + } + } + for via in &pcb.vias { + if via.net == zone.net { + pts.push(via.position); + } + } + for trace in &pcb.traces { + if trace.net != zone.net || trace.layer != zone.layer { + continue; + } + let (dx, dy) = (trace.end.x - trace.start.x, trace.end.y - trace.start.y); + let len = (dx * dx + dy * dy).sqrt(); + let steps = (len / TRACE_SAMPLE_MM).ceil().max(1.0) as usize; + for k in 0..=steps { + let t = k as f64 / steps as f64; + pts.push(Vec2::new(trace.start.x + dx * t, trace.start.y + dy * t)); + } + } + pts +} + /// Build the set of clearance regions to subtract from a zone's pour. fn collect_clearance_regions(pcb: &Pcb, zone: &Zone) -> Vec { let clearance = zone.clearance; @@ -227,16 +285,16 @@ fn thermal_relief_regions( // --- polygon construction helpers ------------------------------------------ -fn ring_to_pts(ring: &[Vec2]) -> Vec { +pub(crate) fn ring_to_pts(ring: &[Vec2]) -> Vec { ring.iter().map(|v| Point2::new(v.x, v.y)).collect() } -fn pts_to_ring(ring: &[Point2]) -> Vec { +pub(crate) fn pts_to_ring(ring: &[Point2]) -> Vec { ring.iter().map(|p| Vec2::new(p.x, p.y)).collect() } /// Force a ring counter-clockwise (poly2d outer-ring convention). -fn ccw(mut ring: Vec) -> Vec { +pub(crate) fn ccw(mut ring: Vec) -> Vec { if poly2d::signed_area_f(&ring) < 0.0 { ring.reverse(); } @@ -244,7 +302,7 @@ fn ccw(mut ring: Vec) -> Vec { } /// Force a ring clockwise (poly2d hole-ring convention). -fn cw(mut ring: Vec) -> Vec { +pub(crate) fn cw(mut ring: Vec) -> Vec { if poly2d::signed_area_f(&ring) > 0.0 { ring.reverse(); } @@ -252,7 +310,7 @@ fn cw(mut ring: Vec) -> Vec { } /// A regular polygon approximating a disc of radius `r` about `c`. -fn circle_poly(c: Vec2, r: f64) -> Poly { +pub(crate) fn circle_poly(c: Vec2, r: f64) -> Poly { let mut pts = Vec::with_capacity(CIRCLE_SEG); for i in 0..CIRCLE_SEG { let a = std::f64::consts::TAU * (i as f64) / (CIRCLE_SEG as f64); @@ -263,7 +321,7 @@ fn circle_poly(c: Vec2, r: f64) -> Poly { /// A capsule (stadium) — the Minkowski sum of segment `a`–`b` with a disc of /// radius `r` — approximated with `CAP_SEG`-segment semicircular caps. -fn capsule_poly(a: Vec2, b: Vec2, r: f64) -> Poly { +pub(crate) fn capsule_poly(a: Vec2, b: Vec2, r: f64) -> Poly { let (dx, dy) = (b.x - a.x, b.y - a.y); let len = (dx * dx + dy * dy).sqrt(); if len < 1e-9 { @@ -305,7 +363,7 @@ fn oriented_rect_poly(center: Vec2, hw: f64, hh: f64, ang: f64) -> Poly { } /// Get the half-width and half-height of a pad's bounding box. -fn pad_half_extents(pad: &Pad) -> (f64, f64) { +pub(crate) fn pad_half_extents(pad: &Pad) -> (f64, f64) { match &pad.shape { PadShape::Circle { diameter } => { let r = diameter / 2.0; @@ -525,6 +583,71 @@ mod tests { assert_eq!(filled[0].polygons[0].len(), 4); // zone outline } + #[test] + fn orphan_island_is_dropped_but_the_plane_survives() { + // An other-net trace slices the GND pour clean across, leaving two + // pieces. Only the lower one carries a GND pad; the upper one reaches + // nothing and must not ship as floating copper. + let mut pcb = test_pcb(); + pcb.traces[0] = Trace { + start: Vec2::new(-1.0, 25.0), + end: Vec2::new(51.0, 25.0), + width: 0.5, + layer: PcbLayer::FCu, + net: "1".to_string(), + source: None, + }; + pcb.footprints.push(gnd_pad_at(Vec2::new(25.0, 10.0))); + + let filled = fill_zones(&pcb); + let area = area_of(&filled[0]); + // The lower piece is ~50 x 24.4 (minus the pad's thermal relief); the + // orphan upper piece is ~50 x 14.4 and must be gone. + assert!( + (1200.0..1230.0).contains(&area), + "only the pad-bearing piece should remain, got {area} mm^2" + ); + + // With no pad at all, the pour still renders: the largest piece is kept + // rather than the zone vanishing from the Gerber. + pcb.footprints.clear(); + let bare = area_of(&fill_zones(&pcb)[0]); + assert!( + (1200.0..1240.0).contains(&bare), + "a zone with no same-net copper keeps its largest piece, got {bare} mm^2" + ); + } + + #[test] + fn a_trace_crossing_an_island_anchors_it() { + // The pour is cut in two and the *upper* piece carries no pad — but a + // same-net trace crosses it, so it is connected copper, not an orphan. + let mut pcb = test_pcb(); + pcb.traces[0] = Trace { + start: Vec2::new(-1.0, 25.0), + end: Vec2::new(51.0, 25.0), + width: 0.5, + layer: PcbLayer::FCu, + net: "1".to_string(), + source: None, + }; + pcb.footprints.push(gnd_pad_at(Vec2::new(25.0, 10.0))); + // A GND trace across the upper piece, both endpoints inside it. + pcb.traces.push(Trace { + start: Vec2::new(5.0, 32.0), + end: Vec2::new(45.0, 32.0), + width: 0.5, + layer: PcbLayer::FCu, + net: "2".to_string(), + source: None, + }); + let area = area_of(&fill_zones(&pcb)[0]); + assert!( + area > 1800.0, + "both pieces are anchored and should survive, got {area} mm^2" + ); + } + #[test] fn fill_zones_empty_pcb() { let mut pcb = test_pcb(); diff --git a/crates/vcad-ecad-pcb/src/drc.rs b/crates/vcad-ecad-pcb/src/drc.rs index 4f4c5623f..3b277040f 100644 --- a/crates/vcad-ecad-pcb/src/drc.rs +++ b/crates/vcad-ecad-pcb/src/drc.rs @@ -2779,14 +2779,22 @@ pub fn analyze_net_continuity(pcb: &Pcb, net: &str) -> NetContinuity { /// expected to be a continuous plane, so a [`build_receipt`](crate) verdict /// should check their realized continuity. Conservative + case-insensitive: /// well-known rail names, `V…`/`…V…` voltage tags (`+3V3`, `5V0`, `1V8`), and -/// `GND`-family grounds. +/// `GND`-family grounds. Word separators are folded away first, so the same +/// rail spelled `V_SUPPLY`, `V-SUPPLY` or `VSUPPLY` reads the same — a motor +/// controller's battery input is often the highest-current net on the board and +/// was being missed purely on punctuation. pub fn is_power_net(name: &str) -> bool { let n = name.trim().to_ascii_uppercase(); - let core = n.trim_start_matches(['+', '-']); + let core: String = n + .trim_start_matches(['+', '-']) + .chars() + .filter(|c| !matches!(c, '_' | '-' | '.' | ' ')) + .collect(); + let core = core.as_str(); const RAILS: &[&str] = &[ "GND", "GROUND", "EARTH", "VSS", "VEE", "AGND", "DGND", "PGND", "SGND", "VCC", "VDD", "VBAT", "VBUS", "VIN", "VOUT", "VREF", "VPP", "AVDD", "DVDD", "AVCC", "DVCC", "VDDA", - "VSSA", "VDDIO", "PWR", "POWER", "VSYS", "VRAW", "B+", + "VSSA", "VDDIO", "PWR", "POWER", "VSYS", "VRAW", "VSUPPLY", "VMOT", "VMOTOR", "B+", ]; if RAILS.iter().any(|r| core == *r || core.starts_with(r)) { return true; @@ -4727,6 +4735,12 @@ mod tests { for f in ["SCL", "MISO", "RESET", "D0", "USB_DP", "CLK", "TX"] { assert!(!is_power_net(f), "{f} should NOT read as power"); } + // Punctuation is not electrical: the same rail spelled with separators + // reads the same. A motor controller's battery input is usually its + // highest-current net, and `V_SUPPLY` was being missed on the underscore. + for t in ["V_SUPPLY", "V-SUPPLY", "VSUPPLY", "P_GND", "V_MOT", "+3.3V"] { + assert!(is_power_net(t), "{t} should read as power"); + } } /// `analyze_power_integrity` auto-selects poured/power nets and flags a diff --git a/crates/vcad-ecad-pcb/src/lib.rs b/crates/vcad-ecad-pcb/src/lib.rs index 60d0fda37..ee7d5edcb 100644 --- a/crates/vcad-ecad-pcb/src/lib.rs +++ b/crates/vcad-ecad-pcb/src/lib.rs @@ -9,6 +9,7 @@ //! - [`router`] -- Trace routing algorithms (grid, push-and-shove, diff pair, length tuning) //! - [`session`] -- Incremental routing session: the in-loop legality oracle //! - [`copper_pour`] -- Zone fill algorithm +//! - [`pour_synth`] -- Copper-pour synthesis: which nets deserve a plane, and its outline //! - [`drc`] -- Design rule checking engine (board vs. its own declared rules) //! - [`dfm`] -- Design-for-Manufacturing checks (board vs. a fab-house profile) //! - [`spatial`] -- R-tree spatial index for copper elements @@ -19,6 +20,7 @@ pub mod critique; pub mod dfm; pub mod drc; pub mod geometry; +pub mod pour_synth; pub mod ratsnest; pub mod router; pub mod session; @@ -33,6 +35,10 @@ pub use drc::{ analyze_net_continuity, analyze_power_integrity, check_drc, is_power_net, DrcRuleType, DrcSeverity, DrcViolation, NetContinuity, NetIsland, }; +pub use pour_synth::{ + decide_pours, ipc2221_current_a, ipc2221_width_mm, synthesize_pours, PourCandidate, PourPolicy, + PourReason, +}; pub use router::grid::GridRouter; pub use session::{Blocker, ProbeResult, RouteSession, SpanId}; pub use spatial::{CopperElement, CopperGeom, SpatialIndex}; diff --git a/crates/vcad-ecad-pcb/src/pour_synth.rs b/crates/vcad-ecad-pcb/src/pour_synth.rs new file mode 100644 index 000000000..8330e3372 --- /dev/null +++ b/crates/vcad-ecad-pcb/src/pour_synth.rs @@ -0,0 +1,1522 @@ +//! Copper-pour synthesis: turn a high-current net into a poured plane instead +//! of a bundle of thin traces. +//! +//! The router synthesizes *traces*. [`crate::copper_pour`] fills zones that +//! already exist. Nothing created zone **definitions** — so a board whose power +//! nets carry tens of amps got hairline copper where a human designer would +//! flood a region. This module closes that gap: it decides which nets deserve a +//! pour, draws the outline, and hands the resulting [`Zone`]s back to the router, +//! which then reaches every pad through the existing plane-stitching machinery. +//! +//! The two halves are deliberately separate, and each is inspectable on its own: +//! +//! * **Policy** — [`PourPolicy`] + [`decide_pours`]: *which* nets, on *which* +//! layers, and *why* ([`PourReason`]). Purely a question about the netlist and +//! the design rules; it touches no geometry. The current-carrying judgement +//! rides the same IPC-2221 closed form the `size_trace_for_current` MCP tool +//! uses ([`ipc2221_width_mm`]), so "this net needs a pour" is a statement about +//! amps, not a name match — even though a name match is the fallback when a +//! board carries no declared current (imported boards never do). +//! * **Geometry** — [`synthesize_outlines`]: given the accepted candidates, draw +//! region floods that cover each net's pads with a margin, stay inside the +//! board, and do not overlap copper already claimed by another pour. +//! +//! Everything downstream is existing machinery. A synthesized zone is an +//! ordinary [`Zone`]: [`crate::copper_pour::fill_zones`] voids it around +//! other-net copper (so a pour never fights the router — it yields to traces), +//! the DRC's clearance and `NetIslands` checks see the filled copper like any +//! other, and `UnstitchedPad` is the check that every pad reached the plane. + +use std::collections::{BTreeMap, BTreeSet}; + +use vcad_ir::ecad::{Footprint, Pad, Pcb, PcbLayer, ThermalReliefStyle, Zone, ZoneFillType}; +use vcad_ir::Vec2; +use vcad_kernel_math::Point2; +use vcad_kernel_sheet::poly2d::{self, Poly}; + +use crate::copper_pour::{ + capsule_poly, ccw, circle_poly, cw, pad_half_extents, pts_to_ring, ring_to_pts, +}; + +// --------------------------------------------------------------------------- +// Ampacity: IPC-2221 closed form +// --------------------------------------------------------------------------- + +/// Copper weight → thickness: 1 oz/ft² ≈ 34.8 µm. Same constant as the +/// `set_stackup` / `size_trace_for_current` MCP surface. +const OZ_TO_MM: f64 = 0.0348; + +/// Millimetres per mil — IPC-2221 is stated in mils. +const MM_PER_MIL: f64 = 0.0254; + +/// IPC-2221 external-conductor constant. +const K_OUTER: f64 = 0.048; + +/// IPC-2221 internal-conductor constant (buried copper sheds heat poorly, so +/// the same current needs roughly twice the cross-section). +const K_INNER: f64 = 0.024; + +/// IPC-2221 conductor ampacity solved for width. +/// +/// `I = k · ΔT^0.44 · A^0.725` with `A` the cross-section in mil² and +/// `k = 0.048` outer / `0.024` inner. This is the *same* closed form the +/// `size_trace_for_current` MCP tool evaluates — one sizing model for the whole +/// product, so "the width this current needs" means the same thing to the router +/// as it does to an agent asking the tool. +/// +/// Returns the width in mm (unsnapped; the MCP tool additionally rounds up to a +/// fab grid, which only matters when the number is going to a fab file). +pub fn ipc2221_width_mm(current_a: f64, copper_oz: f64, temp_rise_c: f64, inner: bool) -> f64 { + if current_a <= 0.0 || copper_oz <= 0.0 || temp_rise_c <= 0.0 { + return 0.0; + } + let k = if inner { K_INNER } else { K_OUTER }; + let area_mil2 = (current_a / (k * temp_rise_c.powf(0.44))).powf(1.0 / 0.725); + let thickness_mil = copper_oz * OZ_TO_MM / MM_PER_MIL; + area_mil2 / thickness_mil * MM_PER_MIL +} + +/// The inverse of [`ipc2221_width_mm`]: the current a conductor of `width_mm` +/// carries at the given temperature rise. +/// +/// Used to state, in amps, what a net *would* get if it were traced rather than +/// poured — the number that makes a `PourReason` legible without a calculator. +pub fn ipc2221_current_a(width_mm: f64, copper_oz: f64, temp_rise_c: f64, inner: bool) -> f64 { + if width_mm <= 0.0 || copper_oz <= 0.0 || temp_rise_c <= 0.0 { + return 0.0; + } + let k = if inner { K_INNER } else { K_OUTER }; + let area_mil2 = (width_mm / MM_PER_MIL) * (copper_oz * OZ_TO_MM / MM_PER_MIL); + k * temp_rise_c.powf(0.44) * area_mil2.powf(0.725) +} + +// --------------------------------------------------------------------------- +// Policy +// --------------------------------------------------------------------------- + +/// Why a net was selected for a copper pour. +/// +/// Ordered by strength of evidence: a declared current is design intent, a wide +/// net class is the designer having already sized the copper, and a power-rail +/// name is the heuristic of last resort for boards that carry neither. +#[derive(Debug, Clone, PartialEq)] +pub enum PourReason { + /// The design declares how much current this net carries, and IPC-2221 says + /// that needs more copper than the router would lay as a trace. + DeclaredCurrent { + /// Declared continuous current (A). + current_a: f64, + /// Width IPC-2221 requires for it (mm). + required_width_mm: f64, + /// Width the router would actually route the net at (mm). + routed_width_mm: f64, + }, + /// The net's class already asks for copper far wider than the board default + /// — the designer sized it for current even if they never wrote the number. + ClassWidth { + /// The net class's trace width (mm). + class_width_mm: f64, + /// The board's default trace width (mm). + default_width_mm: f64, + /// Current that class width carries per IPC-2221 (A). + implied_current_a: f64, + }, + /// The net's name reads as a power/ground rail and it is distributed across + /// enough pads to be worth flooding. The fallback for imported boards, which + /// carry no current annotation at all. + PowerRailName { + /// Pads the net has on the board. + pads: usize, + /// Current the routed trace width would carry per IPC-2221 (A) — what + /// the net gets *without* a pour. + traced_current_a: f64, + }, +} + +/// Which nets get poured, and how generously. The policy half of pour synthesis +/// — no geometry, no board mutation, every knob a number a human can argue with. +#[derive(Debug, Clone)] +pub struct PourPolicy { + /// Master switch. `false` makes synthesis a no-op. + pub enabled: bool, + /// Design intent: net name → continuous current it must carry (A). The + /// strongest signal, and the one an explicit design-intent surface should + /// drive. Empty on every imported board, which is why the fallbacks exist. + pub net_current_a: BTreeMap, + /// Copper weight (oz/ft²) for the ampacity model when the stackup does not + /// state a copper thickness. + pub copper_oz: f64, + /// Allowed conductor temperature rise (°C) for the ampacity model. + pub temp_rise_c: f64, + /// A net whose ampacity-required width is at least this multiple of the + /// width the router would lay is poured instead of traced. + pub width_ratio: f64, + /// Nets with fewer pads than this — and less pad copper than + /// `min_pad_area_mm2` — are never poured: a pour buys nothing over a trace + /// between two pads, and this is what keeps synthesis off small + /// point-to-point boards where the router's traces are already the answer. + pub min_pads: usize, + /// Total pad copper (mm²) that qualifies a net on its own, regardless of pad + /// count. High-current nets are often *few, large* pads — a battery input on + /// two 8x11 mm terminals is the whole reason a pad-count gate is not enough. + pub min_pad_area_mm2: f64, + /// Pour nets whose *name* reads as a power/ground rail (see + /// [`crate::is_power_net`]) once they clear `min_pads`, even with no declared + /// current. Imported boards carry no current annotation, so without this + /// nothing on a VESC or a moteus would ever be poured. + pub pour_power_rails: bool, + /// Outward margin (mm) added around a net's pad cluster to form its outline. + pub margin_mm: f64, + /// A synthesized region covering at least this fraction of the usable board + /// is promoted to a whole-layer flood — the shape a human would actually + /// draw once a net is spread across the board. + pub whole_layer_fraction: f64, + /// Maximum copper layers one net may be poured on. + pub max_layers_per_net: usize, + /// Fraction of a net's pads that must already sit on a layer before it is + /// poured there. Every pad that is *not* on the poured layer costs a + /// stitching via, and a plane reached only through 30-odd vias is worse on + /// via count than the traces it replaced — and each of those vias is another + /// chance for a pad to end up unstitched on a congested board. Set to `0.0` + /// to allow a dedicated inner plane the net does not otherwise touch. + pub min_pad_fraction_on_layer: f64, + /// Synthesized copper islands smaller than this (mm²) are dropped, both from + /// the outline and via the zone's own `min_area` at fill time. Slivers carry + /// no current and strand as `NetIslands`. + pub min_island_mm2: f64, +} + +impl Default for PourPolicy { + fn default() -> Self { + Self { + enabled: true, + net_current_a: BTreeMap::new(), + copper_oz: 1.0, + temp_rise_c: 10.0, + // 2x: a net needing double the routed width has outgrown a trace. + width_ratio: 2.0, + // Six pads is the line between "a connection" and "a distribution + // network". Below it the router's traces are the right answer, and + // holding the line here is what keeps synthesis from rewriting small + // point-to-point boards. + min_pads: 6, + // ~20 mm2 is several signal pads' worth of copper, or one power + // terminal. Below it a net is not a distribution network. + min_pad_area_mm2: 20.0, + pour_power_rails: true, + margin_mm: 1.0, + whole_layer_fraction: 0.55, + max_layers_per_net: 1, + // Half: pour where the net already lives. + min_pad_fraction_on_layer: 0.5, + min_island_mm2: 1.0, + } + } +} + +/// One accepted (net, layer) pour, with the evidence behind it. +#[derive(Debug, Clone)] +pub struct PourCandidate { + /// Net to pour. + pub net: String, + /// Copper layer to pour it on. + pub layer: PcbLayer, + /// Why this net was selected. + pub reason: PourReason, + /// Pads this net has on `layer` (they flood directly; pads elsewhere need a + /// stitching via). + pub pads_on_layer: usize, + /// Pads this net has on the whole board — how much copper the net has to + /// distribute, and the tiebreak when two nets want the same layer. + pub demand_pads: usize, +} + +/// Copper thickness (oz/ft²) the stackup declares for `layer`, falling back to +/// the policy's assumption. +fn copper_oz_for(pcb: &Pcb, layer: PcbLayer, policy: &PourPolicy) -> f64 { + pcb.stackup + .layers + .iter() + .find(|l| l.layer == layer) + .and_then(|l| l.copper_thickness) + .filter(|t| *t > 0.0) + .map(|t| t / OZ_TO_MM) + .unwrap_or(policy.copper_oz) +} + +/// True when `layer` is buried (not the top or bottom of the stackup) — the +/// IPC-2221 derating case. +fn is_inner(copper: &[PcbLayer], layer: PcbLayer) -> bool { + copper.first() != Some(&layer) && copper.last() != Some(&layer) +} + +/// Copper layers in the stackup, top → bottom (FCu/BCu when the stackup is +/// silent — the same fallback the router uses). +fn copper_layers(pcb: &Pcb) -> Vec { + let v: Vec = pcb + .stackup + .layers + .iter() + .map(|l| l.layer) + .filter(|l| l.is_copper()) + .collect(); + if v.is_empty() { + vec![PcbLayer::FCu, PcbLayer::BCu] + } else { + v + } +} + +/// Every pad on the board carrying a non-empty net, as (net, footprint, pad). +fn netted_pads(pcb: &Pcb) -> Vec<(&str, &Footprint, &Pad)> { + let mut out = Vec::new(); + for fp in &pcb.footprints { + for pad in &fp.pads { + if let Some(net) = pad.net.as_deref().filter(|n| !n.is_empty()) { + out.push((net, fp, pad)); + } + } + } + out +} + +/// Net → the copper layers it already owns a zone on. Synthesis never +/// duplicates an existing pour; it only complements one. +fn existing_zone_layers(pcb: &Pcb) -> BTreeMap<&str, Vec> { + let mut m: BTreeMap<&str, Vec> = BTreeMap::new(); + for z in &pcb.zones { + if z.net.is_empty() || z.outline.len() < 3 || !z.layer.is_copper() { + continue; + } + let e = m.entry(z.net.as_str()).or_default(); + if !e.contains(&z.layer) { + e.push(z.layer); + } + } + m +} + +/// Decide which nets deserve a copper pour, and on which layers. +/// +/// Pure policy: reads the netlist, the design rules and the stackup, and returns +/// the accepted (net, layer) candidates with the reason for each. Deterministic +/// (nets in name order). `nets_filter`, when non-empty, restricts the decision to +/// those nets — matching the router's own filter semantics. +pub fn decide_pours(pcb: &Pcb, policy: &PourPolicy, nets_filter: &[String]) -> Vec { + if !policy.enabled { + return Vec::new(); + } + let copper = copper_layers(pcb); + let class_width = crate::drc::build_net_trace_width_map(pcb); + let default_width = pcb.rules.default_rules.trace_width; + let existing = existing_zone_layers(pcb); + + // Pads per net, pad copper per net, and pads per (net, layer). + let mut pads_per_net: BTreeMap<&str, usize> = BTreeMap::new(); + let mut pad_area_per_net: BTreeMap<&str, f64> = BTreeMap::new(); + // Per-net pad extent bbox — how much of the board the net actually spans. + let mut pad_extent_bbox: BTreeMap<&str, (Vec2, Vec2)> = BTreeMap::new(); + let mut pads_per_net_layer: BTreeMap<(&str, PcbLayer), usize> = BTreeMap::new(); + let mut pads_per_layer: BTreeMap = BTreeMap::new(); + for (net, fp, pad) in netted_pads(pcb) { + *pads_per_net.entry(net).or_default() += 1; + let (hw, hh) = pad_half_extents(pad); + *pad_area_per_net.entry(net).or_default() += 4.0 * hw * hh; + let c = crate::geometry::pad_world_position(fp, pad); + let bb = pad_extent_bbox + .entry(net) + .or_insert((Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN))); + bb.0.x = bb.0.x.min(c.x - hw); + bb.0.y = bb.0.y.min(c.y - hh); + bb.1.x = bb.1.x.max(c.x + hw); + bb.1.y = bb.1.y.max(c.y + hh); + for l in pad.layers.iter().filter(|l| l.is_copper()) { + *pads_per_net_layer.entry((net, *l)).or_default() += 1; + *pads_per_layer.entry(*l).or_default() += 1; + } + } + + // --- which nets --------------------------------------------------------- + let mut wanted: Vec<(&str, PourReason)> = Vec::new(); + for (&net, &pads) in &pads_per_net { + // Distributed enough to be a network: many pads, or a lot of pad copper + // (a battery input is two big terminals, not twenty small ones). + let pad_area = pad_area_per_net.get(net).copied().unwrap_or(0.0); + if pads < policy.min_pads && pad_area < policy.min_pad_area_mm2 { + continue; + } + if !nets_filter.is_empty() && !nets_filter.iter().any(|n| n == net) { + continue; + } + // Ampacity is judged on the outermost layer the net has pads on — the + // most favourable case. If even that needs a pour, every layer does. + let layer = copper + .iter() + .copied() + .find(|l| pads_per_net_layer.contains_key(&(net, *l))) + .unwrap_or(PcbLayer::FCu); + let oz = copper_oz_for(pcb, layer, policy); + let inner = is_inner(&copper, layer); + let routed_width = class_width.get(net).copied().unwrap_or(default_width); + + if let Some(¤t_a) = policy.net_current_a.get(net) { + let required = ipc2221_width_mm(current_a, oz, policy.temp_rise_c, inner); + if required >= routed_width * policy.width_ratio { + wanted.push(( + net, + PourReason::DeclaredCurrent { + current_a, + required_width_mm: required, + routed_width_mm: routed_width, + }, + )); + } + // A declared current is authoritative: if it fits in a trace, the + // name heuristic does not get to override it. + continue; + } + if routed_width >= default_width * policy.width_ratio { + wanted.push(( + net, + PourReason::ClassWidth { + class_width_mm: routed_width, + default_width_mm: default_width, + implied_current_a: ipc2221_current_a( + routed_width, + oz, + policy.temp_rise_c, + inner, + ), + }, + )); + continue; + } + if policy.pour_power_rails && crate::is_power_net(net) { + wanted.push(( + net, + PourReason::PowerRailName { + pads, + traced_current_a: ipc2221_current_a( + routed_width, + oz, + policy.temp_rise_c, + inner, + ), + }, + )); + } + } + + // --- which layers ------------------------------------------------------- + // Most-distributed nets pick first: the net with the most copper to move + // gets first refusal on the layer that suits it best. + wanted.sort_by(|a, b| { + pads_per_net + .get(b.0) + .cmp(&pads_per_net.get(a.0)) + .then_with(|| a.0.cmp(b.0)) + }); + + // A copper layer carrying no pads *and* no zone is a bare plane layer. It is + // a fine home for a pour, but only as a fallback: a pad on a bare inner layer + // is a pad that needs a stitching via, and vias are the expensive part. + let zoned_layers: BTreeSet = pcb + .zones + .iter() + .filter(|z| z.layer.is_copper() && !z.net.is_empty() && z.outline.len() >= 3) + .map(|z| z.layer) + .collect(); + + // Board bbox — the denominator for "is this net spread across the board?". + let board_area = { + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for v in &pcb.outline.vertices { + lo.x = lo.x.min(v.x); + lo.y = lo.y.min(v.y); + hi.x = hi.x.max(v.x); + hi.y = hi.y.max(v.y); + } + ((hi.x - lo.x) * (hi.y - lo.y)).max(0.0) + }; + let spread_of = |net: &str| -> bool { + let Some(pts) = pad_extent_bbox.get(net) else { + return false; + }; + let a = (pts.1.x - pts.0.x) * (pts.1.y - pts.0.y); + board_area > 0.0 && a >= board_area * policy.whole_layer_fraction + }; + + // Layers a board-spanning pour has taken. Such a pour floods the whole layer, + // so nothing else can pour there — the geometry pass would only carve the + // newcomer into debris. Two *local* pours may still share a layer; neither + // claims it. + let mut claimed: BTreeSet = BTreeSet::new(); + + let mut out = Vec::new(); + for (net, reason) in wanted { + let taken = existing.get(net).cloned().unwrap_or_default(); + // Rank by how many of this net's pads already sit on the layer: those + // pads flood straight into the pour, and every pad that does *not* costs + // a stitching via. Picking the layer the net already lives on is the + // difference between 5 stitches and 43 on a 3-layer motor board. + let mut chosen: Vec<(PcbLayer, usize)> = copper + .iter() + .filter(|l| !taken.contains(l) && !claimed.contains(l)) + .filter_map(|l| { + let pads_here = pads_per_net_layer.get(&(net, *l)).copied().unwrap_or(0); + // A layer with none of this net's pads is only worth taking when + // it is bare — otherwise it is someone else's routing layer. + let bare = !pads_per_layer.contains_key(l) && !zoned_layers.contains(l); + (pads_here > 0 || bare).then_some((*l, pads_here)) + }) + .collect(); + chosen.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| format!("{:?}", a.0).cmp(&format!("{:?}", b.0))) + }); + chosen.truncate(policy.max_layers_per_net.max(1)); + + let demand_pads = pads_per_net.get(net).copied().unwrap_or(0); + // Reject a layer the net barely touches: the pour would be reached + // almost entirely through stitching vias, which is neither cheaper nor + // more robust than tracing the net. + chosen.retain(|(_, pads_here)| { + *pads_here as f64 >= demand_pads as f64 * policy.min_pad_fraction_on_layer + }); + let spread = spread_of(net); + for (layer, pads_on_layer) in chosen { + if spread { + claimed.insert(layer); + } + out.push(PourCandidate { + net: net.to_string(), + layer, + reason: reason.clone(), + pads_on_layer, + demand_pads, + }); + } + } + out.sort_by(|a, b| { + a.net + .cmp(&b.net) + .then_with(|| format!("{:?}", a.layer).cmp(&format!("{:?}", b.layer))) + }); + out +} + +// --------------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------------- + +/// The Minkowski dilation of a polygon-with-holes by a disc of radius `r`: the +/// polygon unioned with a capsule along every one of its boundary edges. +fn dilate(poly: &Poly, r: f64) -> Vec { + if r <= 0.0 { + return vec![poly.clone()]; + } + let mut parts = vec![poly.clone()]; + let rings = std::iter::once(&poly.outer).chain(poly.holes.iter()); + for ring in rings { + for i in 0..ring.len() { + let a = ring[i]; + let b = ring[(i + 1) % ring.len()]; + parts.push(capsule_poly(Vec2::new(a.x, a.y), Vec2::new(b.x, b.y), r)); + } + } + poly2d::union_all(&parts) +} + +/// The Minkowski erosion of a polygon-with-holes by a disc of radius `r`. +/// +/// Erosion is the polygon minus the band its own boundary sweeps: `P ⊖ disc_r = +/// P \ (∂P ⊕ disc_r)`, and `∂P ⊕ disc_r` is exactly the union of capsules along +/// its edges — so this reuses the same capsule the pour clearance already uses +/// rather than introducing a second offsetting engine. +fn erode(poly: &Poly, r: f64) -> Vec { + if r <= 0.0 { + return vec![poly.clone()]; + } + let mut band = Vec::new(); + let rings = std::iter::once(&poly.outer).chain(poly.holes.iter()); + for ring in rings { + for i in 0..ring.len() { + let a = ring[i]; + let b = ring[(i + 1) % ring.len()]; + band.push(capsule_poly(Vec2::new(a.x, a.y), Vec2::new(b.x, b.y), r)); + } + } + poly2d::difference(std::slice::from_ref(poly), &band) +} + +/// `a ∩ b`, via `a \ (a \ b)` — poly2d exposes union and difference, and the +/// identity is exact for the same snap-rounded arithmetic. +fn intersect(a: &[Poly], b: &[Poly]) -> Vec { + if a.is_empty() || b.is_empty() { + return Vec::new(); + } + let outside = poly2d::difference(a, b); + if outside.is_empty() { + return a.to_vec(); + } + poly2d::difference(a, &outside) +} + +/// The board area a pour may legally occupy: the outline (minus its cutouts) +/// eroded by the edge clearance. +pub fn usable_area(pcb: &Pcb) -> Vec { + if pcb.outline.vertices.len() < 3 { + return Vec::new(); + } + let board = Poly { + outer: ccw(ring_to_pts(&pcb.outline.vertices)), + holes: pcb + .outline + .cutouts + .iter() + .filter(|c| c.len() >= 3) + .map(|c| cw(ring_to_pts(c))) + .collect(), + }; + erode(&board, pcb.rules.edge_clearance.max(0.0)) +} + +/// Total area of a set of polygons (outer minus holes). +fn total_area(polys: &[Poly]) -> f64 { + polys.iter().map(|p| p.area()).sum() +} + +/// The four world-space corners of a pad's bounding box. +fn pad_corners(fp: &Footprint, pad: &Pad) -> Vec { + let c = crate::geometry::pad_world_position(fp, pad); + let ang = (fp.rotation + pad.rotation).to_radians(); + let (ca, sa) = (ang.cos(), ang.sin()); + let (hw, hh) = pad_half_extents(pad); + [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)] + .iter() + .map(|(x, y)| Vec2::new(c.x + x * ca - y * sa, c.y + x * sa + y * ca)) + .collect() +} + +/// The axis-aligned box `min`..`max` as a CCW polygon. +fn box_poly(min: Vec2, max: Vec2) -> Poly { + Poly::new(ccw(vec![ + Point2::new(min.x, min.y), + Point2::new(max.x, min.y), + Point2::new(max.x, max.y), + Point2::new(min.x, max.y), + ])) +} + +/// Draw the region flood for one net: the bounding box of its pads grown by +/// `margin`, clipped to the usable board area, and promoted to the whole usable +/// area once it covers `whole_layer_fraction` of it. +/// +/// A rectangle (or the whole layer) is deliberate — the brief is region floods, +/// not exotic shapes. It is also what a designer draws by hand, it keeps the +/// vertex count low enough that filling it stays cheap on a dense board, and a +/// spread-out net promotes itself to the whole layer rather than producing a +/// rectangle that is a whole-layer flood in all but name. +pub fn synthesize_region( + pad_points: &[Vec2], + usable: &[Poly], + margin: f64, + whole_layer_fraction: f64, +) -> Vec { + if pad_points.is_empty() || usable.is_empty() { + return Vec::new(); + } + let mut min = Vec2::new(f64::MAX, f64::MAX); + let mut max = Vec2::new(f64::MIN, f64::MIN); + for p in pad_points { + min.x = min.x.min(p.x); + min.y = min.y.min(p.y); + max.x = max.x.max(p.x); + max.y = max.y.max(p.y); + } + let rect = box_poly( + Vec2::new(min.x - margin, min.y - margin), + Vec2::new(max.x + margin, max.y + margin), + ); + let usable_area = total_area(usable); + let clipped = intersect(&[rect], usable); + if usable_area > 0.0 && total_area(&clipped) >= usable_area * whole_layer_fraction { + return usable.to_vec(); + } + clipped +} + +/// Turn accepted candidates into zone outlines that do not fight each other. +/// +/// Three rules, in order: +/// +/// 1. **A whole-layer flood owns its layer.** When two board-spanning nets both +/// want to flood one layer, the most distributed one takes it and the other +/// keeps its traces. Carving one board-wide flood out of another leaves +/// confetti, which is not a plane. +/// 2. **Local regions on a shared layer are claimed smallest-first**, so a tight +/// pour (a phase node around its FETs) keeps its copper and a broader one +/// fills in around it — the priority order a human gives overlapping zones. +/// Each later region is cut back by what is already claimed on that layer, +/// inflated by clearance, so two pours never occupy the same copper. The one +/// exception is a disc around the net's own pads: a neighbouring pour is +/// *guaranteed* to be voided there anyway (a pour always clears other-net +/// pads by its clearance), so overlapping exactly there costs no legality and +/// keeps a pad inside a neighbour's box connected to its own plane. +/// 3. **One plane per candidate, and every pad must reach it.** Existing zones +/// can cut a region into pieces; only the largest survives, and if any of the +/// net's pads cannot reach it the whole candidate is dropped. The router +/// treats a poured net as connected *through* its plane and drops its +/// ratsnest, so a pour that misses a pad would silently strand it. Fail +/// closed — that net keeps its traces. +pub fn synthesize_outlines( + pcb: &Pcb, + candidates: &[PourCandidate], + policy: &PourPolicy, +) -> Vec { + let usable = usable_area(pcb); + if usable.is_empty() { + return Vec::new(); + } + let clearance_map = crate::drc::build_net_clearance_map(pcb); + let default_clearance = pcb.rules.default_rules.clearance; + let clearance_of = |net: &str| clearance_map.get(net).copied().unwrap_or(default_clearance); + + // Pad geometry per net. Pads on *every* layer count — an off-layer pad + // reaches the plane through a stitching via dropped at or just beside it, + // and that via has to land inside the pour. + // + // `pad_pts` are the extent points the outline is drawn around; `pad_probes` + // groups them per pad (centre first) for the reach test; `pad_discs` are the + // footprints a neighbouring pour is guaranteed to void anyway. + let mut pad_pts: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut pad_probes: BTreeMap<&str, Vec>> = BTreeMap::new(); + let mut pad_discs: BTreeMap<&str, Vec> = BTreeMap::new(); + let wanted: BTreeSet<&str> = candidates.iter().map(|c| c.net.as_str()).collect(); + for (net, fp, pad) in netted_pads(pcb) { + if !wanted.contains(net) { + continue; + } + let corners = pad_corners(fp, pad); + let c = crate::geometry::pad_world_position(fp, pad); + pad_pts + .entry(net) + .or_default() + .extend(corners.iter().copied()); + let mut probes = Vec::with_capacity(corners.len() + 1); + probes.push(c); + probes.extend(corners.iter().copied()); + pad_probes.entry(net).or_default().push(probes); + let (hw, hh) = pad_half_extents(pad); + pad_discs + .entry(net) + .or_default() + .push(circle_poly(c, hw.max(hh) + clearance_of(net))); + } + + // Raw regions first, so the claim order can be "smallest first". + struct Raw<'a> { + cand: &'a PourCandidate, + region: Vec, + area: f64, + /// This region is the whole usable layer, not a local box. + whole_layer: bool, + } + let usable_total = total_area(&usable); + let mut raws: Vec> = Vec::new(); + for cand in candidates { + let Some(pts) = pad_pts.get(cand.net.as_str()) else { + continue; + }; + let region = synthesize_region(pts, &usable, policy.margin_mm, policy.whole_layer_fraction); + let area = total_area(®ion); + if area < policy.min_island_mm2 { + continue; + } + let whole_layer = (area - usable_total).abs() <= usable_total * 1e-9; + raws.push(Raw { + cand, + region, + area, + whole_layer, + }); + } + + // A whole-layer flood owns its layer. Two board-spanning nets cannot both + // flood one layer: carving one against the other leaves confetti, which is + // neither a plane nor legal (pad-less islands strand as `NetIslands`). The + // most-distributed net keeps the layer and the loser keeps its traces — + // that is the same call a designer makes when they hand a layer to GND. + let mut flooded: BTreeMap = BTreeMap::new(); + for raw in raws.iter().filter(|r| r.whole_layer) { + let claim = (raw.cand.net.as_str(), raw.cand.demand_pads); + match flooded.get(&raw.cand.layer) { + Some(&(_, pads)) if pads >= claim.1 => {} + _ => { + flooded.insert(raw.cand.layer, claim); + } + } + } + raws.retain(|raw| match flooded.get(&raw.cand.layer) { + Some(&(owner, _)) => raw.cand.net == owner, + None => true, + }); + + // Local regions on a shared layer are claimed smallest-first, so a tight + // pour (a phase node around its FETs) keeps its copper and a broader one + // fills in around it — the priority order a human gives overlapping zones. + raws.sort_by(|a, b| { + a.area + .total_cmp(&b.area) + .then_with(|| b.cand.demand_pads.cmp(&a.cand.demand_pads)) + .then_with(|| a.cand.net.cmp(&b.cand.net)) + }); + + // Copper already spoken for on each layer: zones the board already carries + // (synthesis complements them, it never overlaps them) plus every region + // accepted so far. + let mut claimed: BTreeMap> = BTreeMap::new(); + for z in &pcb.zones { + if z.outline.len() < 3 || !z.layer.is_copper() { + continue; + } + claimed.entry(z.layer).or_default().push(Poly { + outer: ccw(ring_to_pts(&z.outline)), + holes: z + .holes + .iter() + .filter(|h| h.len() >= 3) + .map(|h| cw(ring_to_pts(h))) + .collect(), + }); + } + + let mut zones = Vec::new(); + for (rank, raw) in raws.iter().enumerate() { + let net = raw.cand.net.as_str(); + let clearance = clearance_of(net); + let layer_claimed = claimed.get(&raw.cand.layer).cloned().unwrap_or_default(); + let mut region = raw.region.clone(); + if !layer_claimed.is_empty() { + let mut blocked = Vec::new(); + for p in &layer_claimed { + blocked.extend(dilate(p, clearance)); + } + region = poly2d::difference(®ion, &blocked); + // Give back the net's own pad footprints: a neighbouring pour is + // voided there regardless, so this overlap is free. + if let Some(discs) = pad_discs.get(net) { + let keep = intersect(&raw.region, &discs.clone()); + if !keep.is_empty() { + region.extend(keep); + region = poly2d::union_all(®ion); + } + } + } + region.retain(|p| p.area() >= policy.min_island_mm2); + // A pour is one plane, not a scatter. Existing zones can cut a region + // into pieces; the largest is the plane and the rest are debris that + // would strand as `NetIslands` (a pad-less piece) or leave the net in + // disjoint pad groups (the router drops a poured net's ratsnest, so + // nothing would ever join them). Keep the plane; the reach test below + // then decides whether it is good enough to use at all. + if region.len() > 1 { + let best = region + .iter() + .enumerate() + .max_by(|a, b| a.1.area().total_cmp(&b.1.area())) + .map(|(i, _)| i) + .unwrap_or(0); + region = vec![region.swap_remove(best)]; + } + if region.is_empty() { + continue; + } + + // Fail closed: the router treats a poured net as connected *through* its + // plane and drops the net's ratsnest, so a pad the pour cannot reach + // would be silently stranded. Every pad must therefore reach this + // region — reach, not containment: a pad whose copper overlaps the pour + // at all is galvanically part of it, which is also how the DRC's + // connectivity graph reads it. (Containment is the wrong test: the pour + // stops an edge-clearance ring short of the board rim, so a connector + // pad near the edge is never fully inside it.) A candidate with an + // unreachable pad is dropped and that net keeps its traces. + let unreached = pad_probes + .get(net) + .map(|pads| { + pads.iter() + .filter(|probes| { + !probes.iter().any(|p| { + region + .iter() + .any(|r| poly2d::contains_point(r, Point2::new(p.x, p.y))) + }) + }) + .count() + }) + .unwrap_or(usize::MAX); + if unreached > 0 { + log::debug!( + "pour synthesis: dropping {net} on {:?} — {unreached} pad(s) out of reach", + raw.cand.layer + ); + continue; + } + + for piece in ®ion { + zones.push(Zone { + outline: pts_to_ring(&piece.outer), + holes: piece.holes.iter().map(|h| pts_to_ring(h)).collect(), + net: net.to_string(), + layer: raw.cand.layer, + clearance, + // Prune slivers at fill time too: the outline is convex-ish, but + // voids around dense other-net copper can fracture the fill. + min_area: policy.min_island_mm2, + fill_type: ZoneFillType::Solid, + thermal_relief: ThermalReliefStyle::Relief, + thermal_gap: None, + thermal_spoke_width: None, + // Smallest (highest-priority) region claimed first, so a local + // pour outranks the broad plane it sits inside. + priority: (raws.len() - rank) as u32, + }); + } + claimed.entry(raw.cand.layer).or_default().extend(region); + } + zones +} + +/// Synthesize copper pours for the high-current nets of `pcb`. +/// +/// Policy then geometry: [`decide_pours`] picks the nets and layers, +/// [`synthesize_outlines`] draws them. Returns zones to *add* to the board — +/// nets that already own a pour on a layer are left alone, so running this on a +/// board with hand-authored planes complements them rather than duplicating them. +pub fn synthesize_pours(pcb: &Pcb, policy: &PourPolicy, nets_filter: &[String]) -> Vec { + if !policy.enabled { + return Vec::new(); + } + let candidates = decide_pours(pcb, policy, nets_filter); + if candidates.is_empty() { + return Vec::new(); + } + synthesize_outlines(pcb, &candidates, policy) +} + +#[cfg(test)] +mod tests { + use super::*; + use vcad_ir::ecad::*; + + fn rules() -> DesignRules { + DesignRules { + default_rules: NetClassRules { + name: "Default".into(), + trace_width: 0.25, + clearance: 0.2, + via_diameter: 0.8, + via_drill: 0.4, + diff_pair_gap: None, + diff_pair_width: None, + }, + class_rules: vec![], + net_class_assignments: Default::default(), + edge_clearance: 0.5, + hole_to_hole: 0.5, + min_annular_ring: 0.15, + min_drill: 0.2, + } + } + + fn board(w: f64, h: f64) -> Pcb { + Pcb { + outline: BoardOutline { + vertices: vec![ + Vec2::new(0.0, 0.0), + Vec2::new(w, 0.0), + Vec2::new(w, h), + Vec2::new(0.0, h), + ], + cutouts: vec![], + thickness: 1.6, + }, + stackup: LayerStackup { + layers: vec![ + StackupLayer { + layer: PcbLayer::FCu, + copper_thickness: Some(0.035), + dielectric_thickness: Some(1.5), + dielectric_er: Some(4.5), + material: Some("FR4".into()), + }, + StackupLayer { + layer: PcbLayer::BCu, + copper_thickness: Some(0.035), + dielectric_thickness: None, + dielectric_er: None, + material: None, + }, + ], + }, + nets: vec![], + rules: rules(), + footprints: vec![], + traces: vec![], + trace_arcs: vec![], + vias: vec![], + zones: vec![], + keepouts: vec![], + net_ties: vec![], + } + } + + /// A one-pad footprint at `pos` on `layer`, carrying `net`. + fn pad_fp(reference: &str, pos: Vec2, net: &str, layer: PcbLayer) -> Footprint { + Footprint { + reference: reference.into(), + value: String::new(), + footprint_name: "pad".into(), + position: pos, + rotation: 0.0, + front: layer == PcbLayer::FCu, + pads: vec![Pad { + number: "1".into(), + pad_type: PadType::SMD, + shape: PadShape::Rect { + width: 1.0, + height: 1.0, + }, + position: Vec2::new(0.0, 0.0), + rotation: 0.0, + drill: None, + net: Some(net.into()), + layers: vec![layer], + }], + graphics: vec![], + model_3d: None, + properties: Default::default(), + } + } + + /// `n` pads of `net` in a row starting at `x0`, spaced 2mm. + fn pad_row(pcb: &mut Pcb, net: &str, n: usize, x0: f64, y: f64, layer: PcbLayer) { + for i in 0..n { + pcb.footprints.push(pad_fp( + &format!("{net}{i}"), + Vec2::new(x0 + 2.0 * i as f64, y), + net, + layer, + )); + } + } + + // --- ampacity ----------------------------------------------------------- + + #[test] + fn ipc2221_matches_the_shared_sizing_model() { + // Ground truth: the `size_trace_for_current` MCP tool's own closed form + // evaluated at 1 oz / 10 C rise. If these drift apart, the router and + // the tool are answering "how wide for this current?" differently. + for (current, outer, inner) in [ + ( + 1.0, + 0.302_112_880_148_438_8_f64, + 0.785_928_482_810_401_7_f64, + ), + (10.0, 7.235_683_901_965_999_5, 18.823_196_377_372_895), + ] { + let w = ipc2221_width_mm(current, 1.0, 10.0, false); + assert!( + (w - outer).abs() < 1e-9, + "{current} A outer: expected {outer}, got {w}" + ); + let wi = ipc2221_width_mm(current, 1.0, 10.0, true); + assert!( + (wi - inner).abs() < 1e-9, + "{current} A inner: expected {inner}, got {wi}" + ); + // Buried copper derates: the same current needs ~2.6x the width. + assert!(wi > w * 2.0); + } + } + + #[test] + fn ampacity_round_trips_through_its_inverse() { + for current in [0.5, 2.0, 15.0] { + let w = ipc2221_width_mm(current, 1.0, 10.0, false); + let back = ipc2221_current_a(w, 1.0, 10.0, false); + assert!( + (back - current).abs() < current * 1e-6, + "{current} A -> {w} mm -> {back} A" + ); + } + assert_eq!(ipc2221_width_mm(0.0, 1.0, 10.0, false), 0.0); + assert_eq!(ipc2221_current_a(-1.0, 1.0, 10.0, false), 0.0); + } + + // --- policy ------------------------------------------------------------- + + #[test] + fn declared_current_beyond_a_trace_selects_a_pour() { + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "MOTOR_A", 8, 5.0, 10.0, PcbLayer::FCu); + let mut policy = PourPolicy::default(); + policy.net_current_a.insert("MOTOR_A".into(), 25.0); + let d = decide_pours(&pcb, &policy, &[]); + assert!(!d.is_empty(), "25 A must not be routed as a 0.25 mm trace"); + assert!(matches!( + d[0].reason, + PourReason::DeclaredCurrent { current_a, .. } if current_a == 25.0 + )); + } + + #[test] + fn declared_current_that_fits_a_trace_is_left_alone() { + let mut pcb = board(40.0, 30.0); + // A power-rail *name* — the fallback would fire if the declared current + // did not take precedence. + pad_row(&mut pcb, "+3V3", 8, 5.0, 10.0, PcbLayer::FCu); + let mut policy = PourPolicy::default(); + policy.net_current_a.insert("+3V3".into(), 0.2); + assert!( + decide_pours(&pcb, &policy, &[]).is_empty(), + "0.2 A fits in a trace; a declared current is authoritative" + ); + } + + #[test] + fn wide_net_class_selects_a_pour_without_a_declared_current() { + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "BUS", 8, 5.0, 10.0, PcbLayer::FCu); + pcb.rules.class_rules.push(NetClassRules { + name: "power".into(), + trace_width: 1.5, + clearance: 0.3, + via_diameter: 0.8, + via_drill: 0.4, + diff_pair_gap: None, + diff_pair_width: None, + }); + pcb.rules + .net_class_assignments + .insert("power".into(), vec!["BUS".into()]); + let d = decide_pours(&pcb, &PourPolicy::default(), &[]); + assert!(matches!(d[0].reason, PourReason::ClassWidth { .. })); + } + + #[test] + fn power_rail_name_is_the_fallback_and_respects_min_pads() { + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "GND", 8, 5.0, 10.0, PcbLayer::FCu); + pad_row(&mut pcb, "SIG", 8, 5.0, 20.0, PcbLayer::FCu); + let d = decide_pours(&pcb, &PourPolicy::default(), &[]); + assert!(d.iter().any(|c| c.net == "GND")); + assert!( + !d.iter().any(|c| c.net == "SIG"), + "a signal net is not a plane" + ); + + // Below min_pads nothing is poured — small point-to-point boards keep + // the router's traces. + let mut small = board(40.0, 30.0); + pad_row(&mut small, "GND", 3, 5.0, 10.0, PcbLayer::FCu); + assert!(decide_pours(&small, &PourPolicy::default(), &[]).is_empty()); + } + + #[test] + fn nets_filter_and_disable_switch_are_honored() { + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "GND", 8, 5.0, 10.0, PcbLayer::FCu); + pad_row(&mut pcb, "+5V", 8, 5.0, 20.0, PcbLayer::FCu); + let all = decide_pours(&pcb, &PourPolicy::default(), &[]); + assert_eq!( + all.iter().map(|c| c.net.as_str()).collect::>(), + BTreeSet::from(["+5V", "GND"]) + ); + let only_gnd = decide_pours(&pcb, &PourPolicy::default(), &["GND".into()]); + assert!(only_gnd.iter().all(|c| c.net == "GND")); + + let off = PourPolicy { + enabled: false, + ..Default::default() + }; + assert!(decide_pours(&pcb, &off, &[]).is_empty()); + assert!(synthesize_pours(&pcb, &off, &[]).is_empty()); + } + + #[test] + fn an_existing_zone_is_complemented_never_duplicated() { + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "GND", 8, 5.0, 10.0, PcbLayer::FCu); + pad_row(&mut pcb, "GND_B", 8, 5.0, 20.0, PcbLayer::BCu); + pcb.zones.push(Zone { + outline: vec![ + Vec2::new(0.5, 0.5), + Vec2::new(39.5, 0.5), + Vec2::new(39.5, 29.5), + Vec2::new(0.5, 29.5), + ], + holes: vec![], + net: "GND".into(), + layer: PcbLayer::FCu, + clearance: 0.2, + min_area: 0.0, + fill_type: ZoneFillType::Solid, + thermal_relief: ThermalReliefStyle::Relief, + thermal_gap: None, + thermal_spoke_width: None, + priority: 0, + }); + let d = decide_pours(&pcb, &PourPolicy::default(), &[]); + assert!( + !d.iter().any(|c| c.net == "GND" && c.layer == PcbLayer::FCu), + "GND already owns FCu: {d:?}" + ); + } + + // --- geometry ----------------------------------------------------------- + + #[test] + fn a_local_cluster_becomes_a_margined_region_not_the_whole_layer() { + let pcb = board(60.0, 60.0); + let usable = usable_area(&pcb); + let pts = vec![ + Vec2::new(10.0, 10.0), + Vec2::new(14.0, 10.0), + Vec2::new(14.0, 14.0), + Vec2::new(10.0, 14.0), + ]; + let region = synthesize_region(&pts, &usable, 1.0, 0.55); + assert_eq!(region.len(), 1); + // 4x4 cluster + 1mm margin on each side = 6x6. + assert!( + (region[0].area() - 36.0).abs() < 0.5, + "expected a ~36 mm^2 region, got {}", + region[0].area() + ); + // The cluster and its margin are inside; a far corner is not. + assert!(poly2d::contains_point(®ion[0], Point2::new(12.0, 12.0))); + assert!(poly2d::contains_point(®ion[0], Point2::new(9.5, 9.5))); + assert!(!poly2d::contains_point(®ion[0], Point2::new(50.0, 50.0))); + } + + #[test] + fn a_spread_net_promotes_itself_to_a_whole_layer_flood() { + let pcb = board(60.0, 60.0); + let usable = usable_area(&pcb); + let usable_a = total_area(&usable); + let pts = vec![ + Vec2::new(3.0, 3.0), + Vec2::new(57.0, 3.0), + Vec2::new(57.0, 57.0), + Vec2::new(3.0, 57.0), + ]; + let region = synthesize_region(&pts, &usable, 1.0, 0.55); + assert!( + (total_area(®ion) - usable_a).abs() < 1e-6, + "a board-spanning cluster should flood the whole usable layer" + ); + } + + #[test] + fn a_region_never_crosses_the_board_edge_or_a_cutout() { + let mut pcb = board(30.0, 30.0); + pcb.outline.cutouts.push(vec![ + Vec2::new(12.0, 12.0), + Vec2::new(18.0, 12.0), + Vec2::new(18.0, 18.0), + Vec2::new(12.0, 18.0), + ]); + let usable = usable_area(&pcb); + // Pads spanning the whole board: the flood must still respect the + // edge-clearance ring and the cutout's clearance ring. + let pts = vec![Vec2::new(1.0, 1.0), Vec2::new(29.0, 29.0)]; + let region = synthesize_region(&pts, &usable, 2.0, 2.0); + assert!(!region.is_empty()); + for p in ®ion { + // Outside the board and inside the cutout are both forbidden. + assert!(!poly2d::contains_point(p, Point2::new(0.1, 15.0))); + assert!(!poly2d::contains_point(p, Point2::new(15.0, 15.0))); + } + // 30x30 eroded by 0.5 = 29x29 (841), minus the 6x6 cutout grown by 0.5 + // (49) = ~792. + let a = total_area(®ion); + assert!((a - 792.0).abs() < 6.0, "usable area {a} mm^2"); + } + + #[test] + fn a_net_of_few_large_pads_qualifies_on_pad_area() { + // A battery input: two big terminals, well under `min_pads`. Pad count + // alone would leave the highest-current net on the board un-poured. + let mut pcb = board(40.0, 30.0); + for (i, x) in [8.0_f64, 30.0].iter().enumerate() { + let mut fp = pad_fp( + &format!("J{i}"), + Vec2::new(*x, 15.0), + "V_SUPPLY", + PcbLayer::FCu, + ); + fp.pads[0].shape = PadShape::Rect { + width: 8.0, + height: 11.0, + }; + pcb.footprints.push(fp); + } + // A few more so the net is a network, still below min_pads. + pad_row(&mut pcb, "V_SUPPLY", 3, 14.0, 8.0, PcbLayer::FCu); + // Something on the back copper, so BCu is not a free plane layer. + pcb.footprints + .push(pad_fp("SIGB", Vec2::new(20.0, 25.0), "SIG", PcbLayer::BCu)); + let d = decide_pours(&pcb, &PourPolicy::default(), &[]); + assert!( + d.iter().any(|c| c.net == "V_SUPPLY"), + "5 pads but 179 mm^2 of pad copper is a power net: {d:?}" + ); + + // The same net with small pads stays below both gates. + let mut small = board(40.0, 30.0); + pad_row(&mut small, "V_SUPPLY", 5, 8.0, 15.0, PcbLayer::FCu); + assert!(decide_pours(&small, &PourPolicy::default(), &[]).is_empty()); + } + + #[test] + fn a_board_spanning_flood_takes_its_layer_exclusively() { + // Two rails, each spread across the board, on a three-layer stackup. + // Both would flood a whole layer, and two whole-layer floods cannot + // share one — so they end up on different layers, most-distributed net + // first. + let mut pcb = board(40.0, 30.0); + pcb.stackup.layers = [PcbLayer::FCu, PcbLayer::In1Cu, PcbLayer::BCu] + .into_iter() + .map(|layer| StackupLayer { + layer, + copper_thickness: Some(0.035), + dielectric_thickness: Some(0.2), + dielectric_er: Some(4.5), + material: Some("FR4".into()), + }) + .collect(); + for (i, x) in [3.0_f64, 20.0, 37.0].iter().enumerate() { + for (j, y) in [3.0_f64, 15.0, 27.0].iter().enumerate() { + pcb.footprints.push(pad_fp( + &format!("G{i}{j}"), + Vec2::new(*x, *y), + "GND", + PcbLayer::FCu, + )); + if i + j < 4 { + pcb.footprints.push(pad_fp( + &format!("P{i}{j}"), + Vec2::new(x + 1.5, y + 1.5), + "+5V", + PcbLayer::BCu, + )); + } + } + } + let d = decide_pours(&pcb, &PourPolicy::default(), &[]); + let gnd = d.iter().find(|c| c.net == "GND").expect("GND poured"); + let v5 = d.iter().find(|c| c.net == "+5V").expect("+5V poured"); + assert_eq!( + gnd.layer, + PcbLayer::FCu, + "GND's pads are all on FCu: flooding it costs zero stitching vias" + ); + assert_ne!(v5.layer, gnd.layer, "a board-spanning flood owns its layer"); + } + + #[test] + fn the_layer_chosen_is_the_one_that_needs_the_fewest_stitches() { + // A bare inner layer looks like a tempting dedicated plane, but every + // pad on another layer then costs a stitching via. The layer the net + // already lives on wins. + let mut pcb = board(40.0, 30.0); + pcb.stackup.layers = [PcbLayer::FCu, PcbLayer::In1Cu, PcbLayer::BCu] + .into_iter() + .map(|layer| StackupLayer { + layer, + copper_thickness: Some(0.035), + dielectric_thickness: Some(0.2), + dielectric_er: Some(4.5), + material: Some("FR4".into()), + }) + .collect(); + pad_row(&mut pcb, "GND", 10, 4.0, 8.0, PcbLayer::FCu); + pcb.footprints + .push(pad_fp("GB", Vec2::new(30.0, 20.0), "GND", PcbLayer::BCu)); + let d = decide_pours(&pcb, &PourPolicy::default(), &[]); + let gnd = d.iter().find(|c| c.net == "GND").expect("GND poured"); + assert_eq!(gnd.layer, PcbLayer::FCu, "10 pads flood, 1 stitches"); + assert_eq!(gnd.pads_on_layer, 10); + } + + #[test] + fn geometry_refuses_to_carve_one_whole_layer_flood_out_of_another() { + // The geometry pass is the backstop when two candidates land on one + // layer anyway: the more distributed net keeps it rather than both + // ending up as debris. + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "GND", 12, 3.0, 5.0, PcbLayer::FCu); + pcb.footprints + .push(pad_fp("GNDX", Vec2::new(36.0, 26.0), "GND", PcbLayer::FCu)); + pad_row(&mut pcb, "+5V", 8, 3.0, 15.0, PcbLayer::FCu); + pcb.footprints + .push(pad_fp("V5X", Vec2::new(36.0, 24.0), "+5V", PcbLayer::FCu)); + let reason = PourReason::PowerRailName { + pads: 0, + traced_current_a: 0.0, + }; + let cands = vec![ + PourCandidate { + net: "GND".into(), + layer: PcbLayer::FCu, + reason: reason.clone(), + pads_on_layer: 13, + demand_pads: 13, + }, + PourCandidate { + net: "+5V".into(), + layer: PcbLayer::FCu, + reason, + pads_on_layer: 9, + demand_pads: 9, + }, + ]; + let zones = synthesize_outlines(&pcb, &cands, &PourPolicy::default()); + let nets: BTreeSet<&str> = zones.iter().map(|z| z.net.as_str()).collect(); + assert_eq!( + nets, + BTreeSet::from(["GND"]), + "GND has more pads, so it keeps the layer: {zones:?}" + ); + } + + #[test] + fn a_candidate_carved_to_scraps_by_an_existing_zone_is_dropped() { + // A hand-authored plane already owns most of the layer. What is left is + // not a plane for the second net, and some of its pads cannot reach it — + // so the net keeps its traces rather than shipping stranded copper. + let mut pcb = board(60.0, 20.0); + pad_row(&mut pcb, "+5V", 8, 4.0, 10.0, PcbLayer::FCu); + pcb.footprints + .push(pad_fp("SIGB", Vec2::new(30.0, 15.0), "SIG", PcbLayer::BCu)); + pcb.zones.push(Zone { + outline: vec![ + Vec2::new(6.0, 0.5), + Vec2::new(59.5, 0.5), + Vec2::new(59.5, 19.5), + Vec2::new(6.0, 19.5), + ], + holes: vec![], + net: "GND".into(), + layer: PcbLayer::FCu, + clearance: 0.2, + min_area: 0.0, + fill_type: ZoneFillType::Solid, + thermal_relief: ThermalReliefStyle::Relief, + thermal_gap: None, + thermal_spoke_width: None, + priority: 0, + }); + assert!( + decide_pours(&pcb, &PourPolicy::default(), &[]) + .iter() + .any(|c| c.net == "+5V"), + "the policy still wants it" + ); + assert!( + synthesize_pours(&pcb, &PourPolicy::default(), &[]).is_empty(), + "but the geometry cannot give it a plane every pad reaches" + ); + } + + #[test] + fn two_pours_on_one_layer_do_not_overlap() { + let mut pcb = board(60.0, 30.0); + pad_row(&mut pcb, "+12V", 6, 4.0, 8.0, PcbLayer::FCu); + pad_row(&mut pcb, "+5V", 6, 40.0, 8.0, PcbLayer::FCu); + // Wide margin so the raw boxes would overlap without the claim pass. + let policy = PourPolicy { + margin_mm: 12.0, + whole_layer_fraction: 2.0, + ..Default::default() + }; + let zones = synthesize_pours(&pcb, &policy, &[]); + assert!(zones.len() >= 2, "both rails should pour: {zones:?}"); + let polys: Vec<(String, Poly)> = zones + .iter() + .map(|z| { + ( + z.net.clone(), + Poly { + outer: ccw(ring_to_pts(&z.outline)), + holes: z.holes.iter().map(|h| cw(ring_to_pts(h))).collect(), + }, + ) + }) + .collect(); + for (i, (net_a, a)) in polys.iter().enumerate() { + for (net_b, b) in polys.iter().skip(i + 1) { + if net_a == net_b { + continue; + } + let overlap = + total_area(&intersect(std::slice::from_ref(a), std::slice::from_ref(b))); + // Only the pad-disc give-back may overlap, and each pad disc is + // under 2 mm^2 here. + assert!( + overlap < 6.0, + "{net_a} and {net_b} overlap by {overlap} mm^2" + ); + } + } + } + + #[test] + fn every_pad_of_a_poured_net_is_inside_its_pour() { + let mut pcb = board(60.0, 40.0); + pad_row(&mut pcb, "GND", 8, 5.0, 8.0, PcbLayer::FCu); + // An off-layer pad: it reaches the plane through a stitching via, which + // has to land inside the pour. + pcb.footprints + .push(pad_fp("GNDB", Vec2::new(30.0, 30.0), "GND", PcbLayer::BCu)); + let zones = synthesize_pours(&pcb, &PourPolicy::default(), &[]); + assert!(!zones.is_empty()); + let fcu: Vec = zones + .iter() + .filter(|z| z.layer == PcbLayer::FCu) + .map(|z| Poly::new(ccw(ring_to_pts(&z.outline)))) + .collect(); + assert!(!fcu.is_empty(), "GND should pour on FCu"); + for fp in &pcb.footprints { + for pad in &fp.pads { + let c = crate::geometry::pad_world_position(fp, pad); + assert!( + fcu.iter() + .any(|p| poly2d::contains_point(p, Point2::new(c.x, c.y))), + "pad {} at ({}, {}) is outside the GND pour", + fp.reference, + c.x, + c.y + ); + } + } + } + + #[test] + fn synthesized_zones_fill_and_carry_their_net() { + let mut pcb = board(40.0, 30.0); + pad_row(&mut pcb, "GND", 8, 5.0, 10.0, PcbLayer::FCu); + let zones = synthesize_pours(&pcb, &PourPolicy::default(), &[]); + assert!(!zones.is_empty()); + pcb.zones = zones; + let filled = crate::copper_pour::fill_zones(&pcb); + assert_eq!(filled.len(), pcb.zones.len()); + for f in &filled { + assert_eq!(f.net, "GND"); + assert!(!f.polygons.is_empty(), "a synthesized zone must fill"); + } + } +} diff --git a/crates/vcad-ecad-pcb/src/router/auto.rs b/crates/vcad-ecad-pcb/src/router/auto.rs index fdf5ab001..5f113dabc 100644 --- a/crates/vcad-ecad-pcb/src/router/auto.rs +++ b/crates/vcad-ecad-pcb/src/router/auto.rs @@ -14,9 +14,10 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use rayon::prelude::*; -use vcad_ir::ecad::{Footprint, Pad, PadShape, Pcb, PcbLayer}; +use vcad_ir::ecad::{Footprint, Pad, PadShape, Pcb, PcbLayer, Zone}; use vcad_ir::Vec2; +use crate::pour_synth::{synthesize_pours, PourPolicy}; use crate::ratsnest::{compute_ratsnest, NetConnection, Netlist, NetlistNet, RatsnestLine}; use crate::session::{RouteSession, SpanId}; use crate::spatial::{point_in_polygon, CopperElement, CopperGeom}; @@ -106,6 +107,12 @@ pub struct RouteAllResult { /// Overall routability in `[0, 1]`: the fraction of attempted connections /// that were routed. `1.0` means a fully-routed board. pub routability: f64, + /// Copper pours synthesized for high-current nets (see + /// [`crate::pour_synth`]). **Callers must add these to the board along with + /// the traces and vias**: the routing above assumes them — a poured net is + /// carried by its plane, so its pads were stitched to the plane instead of + /// traced to each other. Empty when nothing was synthesized. + pub zones: Vec, } /// Knobs for [`route_all_with_opts`]. @@ -145,6 +152,12 @@ pub struct RouteOptions { /// inspect the raw emitter output; it removes nothing electrically live, so /// routability and the routed/unrouted split read the same either way. pub prune_dangling_copper: bool, + /// Copper-pour synthesis policy: which nets get a plane instead of traces + /// (see [`crate::pour_synth`]). Synthesized zones come back in + /// [`RouteAllResult::zones`] and the caller must add them to the board — + /// the routing assumes them. Set `enabled: false` to route a board exactly + /// as it was authored. + pub pour_policy: PourPolicy, } impl RouteOptions { @@ -185,6 +198,7 @@ impl Default for RouteOptions { effort: 1.0, priority_nets: Vec::new(), prune_dangling_copper: true, + pour_policy: PourPolicy::default(), } } } @@ -235,6 +249,10 @@ fn negotiate_first_regime(pcb: &Pcb) -> bool { gpu_negotiation_enabled() && copper_layers(pcb).len() >= 4 } +/// Same-net landing points the plane-stitch maze rescue will try, nearest +/// first, before reporting a pad unstitchable. +const PLANE_RESCUE_TARGETS: usize = 8; + /// History cost (mm-equivalent) deposited on a contested corridor per /// negotiation round. A gentle, accumulating bias: enough that a flexible net /// with another way around bows out of a repeatedly-contested band, but small @@ -332,6 +350,32 @@ pub fn route_all_with_opts( nets_filter: &[String], opts: &RouteOptions, ) -> RouteAllResult { + // Synthesize copper pours for the high-current nets before anything reads + // the board's zones. A synthesized plane changes the routing problem exactly + // as a hand-authored one does — its net is carried by the plane, not by + // pad-to-pad traces — so it has to be in place before the ratsnest is taken. + // Nets that already own a pour are left alone, so this complements existing + // zones rather than duplicating them. + let synthesized = synthesize_pours(pcb, &opts.pour_policy, nets_filter); + let poured_board; + let pcb = if synthesized.is_empty() { + pcb + } else { + log::info!( + "pour synthesis: {} zone(s) across {} net(s)", + synthesized.len(), + synthesized + .iter() + .map(|z| z.net.as_str()) + .collect::>() + .len(), + ); + let mut p = pcb.clone(); + p.zones.extend(synthesized.iter().cloned()); + poured_board = p; + &poured_board + }; + let netlist = netlist_from_pads(pcb); let mut rats = compute_ratsnest(pcb, &netlist); @@ -533,9 +577,12 @@ pub fn route_all_with_opts( // its own layer — get a dog-bone escape via to the back copper. This only // *adds* copper for already-unrouted nets, so it can't disturb the routes // rip-up settled; a connection it still can't route stays unrouted. + // Holes already drilled in the board — consulted before every escape / + // stitch via so none of them lands hole-to-hole illegal. + let holes = board_holes(pcb); let mut still_unrouted = Vec::new(); for (net, from, to) in pending { - match try_route_fanout(&mut session, pcb, width, &net, from, to, &placed) { + match try_route_fanout(&mut session, pcb, width, &net, from, to, &placed, &holes) { Some(p) => placed.push(p), None => still_unrouted.push((net, from, to)), } @@ -559,7 +606,27 @@ pub fn route_all_with_opts( // Stitch the planed nets' pads down to their planes (issue #289 part 2). // Runs after signal routing so each stitching via avoids the routed copper // and is probed on every copper layer it spans before being committed. - let mut stitch = stitch_planes(&mut session, pcb, &planes, nets_filter, &placed, width); + let mut stitch = stitch_planes( + &mut session, + pcb, + &planes, + nets_filter, + &placed, + width, + &holes, + ); + { + let mut per_net: BTreeMap<&str, (usize, usize)> = BTreeMap::new(); + for (_, n) in &stitch.vias { + per_net.entry(n.as_str()).or_default().0 += 1; + } + for (n, _) in &stitch.failed_pads { + per_net.entry(n.as_str()).or_default().1 += 1; + } + for (n, (vias, failed)) in &per_net { + log::info!("plane stitch {n}: {vias} via(s), {failed} pad(s) unstitched"); + } + } // Maze rescue for pads the radial dog-bone couldn't escape (issue: a // fine-pitch pad boxed in by already-routed copper has no *straight* @@ -604,7 +671,10 @@ pub fn route_all_with_opts( targets.retain(|t| dist(*t, pad_pt) > 1e-6); targets.sort_by(|a, b| dist(*a, pad_pt).total_cmp(&dist(*b, pad_pt))); let mut rescued = false; - for t in targets.into_iter().take(3) { + // A plane offers many galvanically equivalent landing points; the three + // nearest are often all boxed in by the same local congestion, so give + // the search a wider set before declaring the pad unstitchable. + for t in targets.into_iter().take(PLANE_RESCUE_TARGETS) { if let Some(p) = try_route( &mut session, pcb, @@ -695,12 +765,19 @@ pub fn route_all_with_opts( // copper is still hole-to-hole illegal. Enforces the invariant that an // autoroute pass never returns HoleToHole or SameNetBypass violations of // its own making. - let legal = super::legalize::legalize(pcb, &mut traces, &mut vias); - if legal.merged_vias > 0 || legal.pruned_traces > 0 || !legal.demoted.is_empty() { + let stitch_via_pts: Vec = stitch.vias.iter().map(|(p, _)| *p).collect(); + let legal = super::legalize::legalize(pcb, &mut traces, &mut vias, &stitch_via_pts); + if legal.merged_vias > 0 + || legal.pruned_traces > 0 + || !legal.demoted.is_empty() + || !legal.dropped_stitches.is_empty() + { log::info!( - "post-route legalization: merged {} vias, pruned {} traces, demoted {} nets", + "post-route legalization: merged {} vias, pruned {} traces, dropped {} stitch vias, \ + demoted {} nets", legal.merged_vias, legal.pruned_traces, + legal.dropped_stitches.len(), legal.demoted.len(), ); } @@ -817,6 +894,7 @@ pub fn route_all_with_opts( unrouted_nets: unrouted.into_iter().collect(), diagnostics, routability, + zones: synthesized, } } @@ -2504,6 +2582,7 @@ fn try_push_shove( /// maze can't connect them — so it either commits a fully clearance-legal route /// or mutates nothing. Because it only ever *adds* copper for an /// already-unrouted net, running it never disturbs the routes rip-up settled. +#[allow(clippy::too_many_arguments)] fn try_route_fanout( session: &mut RouteSession, pcb: &Pcb, @@ -2512,6 +2591,7 @@ fn try_route_fanout( from: Vec2, to: Vec2, placed: &[Placed], + holes: &[Hole], ) -> Option { let w = session.width_for(net, width); let hw = w / 2.0; @@ -2537,6 +2617,7 @@ fn try_route_fanout( &via_pts, &mut spans, false, + holes, ) { Some(e) => e, None => { @@ -2565,6 +2646,7 @@ fn try_route_fanout( &via_pts, &mut spans, false, + holes, ) { Some(e) => e, None => { @@ -2657,10 +2739,36 @@ fn escape_endpoint( extra_vias: &[Vec2], spans: &mut Vec, force_fanout: bool, + holes: &[Hole], ) -> Option { let hw = w / 2.0; + // Hole-to-hole legality against every drill already on the board and every + // via this pass has placed. Checked *here*, at placement, so the dog-bone + // ring search simply steps out to a drillable spot — rather than committing + // an illegal drill and leaving post-route legalization to strip the net's + // whole set of stitches to get rid of it. + let drill_r = pcb.rules.default_rules.via_drill / 2.0; + let min_hole_gap = pcb.rules.hole_to_hole; + let hole_legal = |p: Vec2| -> bool { + let clears = |other: Vec2, r: f64| { + let d = dist(p, other); + // A coincident drill is the reuse case, decided by `reused` above; + // it is never a second hole, so it is not a conflict here. + d < 0.05 || d - drill_r - r >= min_hole_gap - 1e-6 + }; + holes.iter().all(|&(c, r)| clears(c, r)) + && placed + .iter() + .flat_map(|pl| pl.via_pts.iter().map(|&(vp, _, _)| vp)) + .chain(extra_vias.iter().copied()) + .all(|vp| clears(vp, drill_r)) + }; + let via_legal = |session: &RouteSession, p: Vec2| -> bool { + if !hole_legal(p) { + return false; + } let disc = CopperGeom::Disc { center: p, r: via_r, @@ -2818,6 +2926,38 @@ fn rollback(session: &mut RouteSession, spans: &[SpanId]) { } } +/// A drilled hole as `(center, radius)` — the geometry the hole-to-hole rule +/// judges. Radius, not diameter, so the edge gap is `dist - ra - rb`. +pub(super) type Hole = (Vec2, f64); + +/// Every hole already drilled in the board: existing vias and every pad with a +/// drill. Built once per routing call and consulted before a new via is placed, +/// so an escape/stitch via never lands hole-to-hole illegal in the first place. +/// +/// Without this the router placed the via wherever copper clearance allowed and +/// left [`super::legalize`] to clean up — and its only lever is to strip the +/// offending net's new copper entirely. For a plane-stitched power net that is +/// catastrophic: one bad drill on a moteus GND pad cost all 43 of that net's +/// stitching vias, leaving the pour connected to nothing. +fn board_holes(pcb: &Pcb) -> Vec { + let mut holes: Vec = pcb + .vias + .iter() + .map(|v| (v.position, v.drill / 2.0)) + .collect(); + for fp in &pcb.footprints { + for pad in &fp.pads { + if let Some(drill) = &pad.drill { + holes.push(( + crate::geometry::pad_world_position(fp, pad), + drill.diameter / 2.0, + )); + } + } + } + holes +} + /// Copper layers present in the stackup, top → bottom (FCu/BCu fallback). pub(super) fn copper_layers(pcb: &Pcb) -> Vec { let v: Vec = pcb @@ -2881,6 +3021,7 @@ fn stitch_planes( nets_filter: &[String], placed: &[Placed], width: f64, + holes: &[Hole], ) -> Stitch { let mut out = Stitch { stubs: Vec::new(), @@ -2893,6 +3034,13 @@ fn stitch_planes( } let via_r = pcb.rules.default_rules.via_diameter / 2.0; let copper = copper_layers(pcb); + // Drilled holes, growing as this pass places stitches. Stitch vias must + // clear each other's drills too — and *across* nets, where the same-net + // `extra` reuse list below cannot see them. A GND stitch landing hole-to-hole + // illegal against a +3V3 stitch is exactly the kind of single bad drill that + // used to cost the whole net its stitching in post-route legalization. + let mut holes: Vec = holes.to_vec(); + let drill_r = pcb.rules.default_rules.via_drill / 2.0; for fp in &pcb.footprints { for pad in &fp.pads { @@ -2936,7 +3084,7 @@ fn stitch_planes( let mut spans: Vec = Vec::new(); match escape_endpoint( session, pcb, net, pad_pt, pad_layer, w, clearance, via_r, &copper, placed, &extra, - &mut spans, fine_pitch, + &mut spans, fine_pitch, &holes, ) { Some(e) => { if let Some((a, b)) = e.stub { @@ -2944,6 +3092,7 @@ fn stitch_planes( } if let Some(v) = e.via { out.vias.push((v, net.clone())); + holes.push((v, drill_r)); } out.nets.insert(net.clone()); // spans stay committed — the stitching copper is part of the board. @@ -4487,7 +4636,7 @@ mod tests { use_push_shove: false, effort: 1.0, priority_nets: Vec::new(), - prune_dangling_copper: true, + ..Default::default() }, ); assert_eq!( @@ -4526,7 +4675,7 @@ mod tests { use_push_shove: false, effort: 1.0, priority_nets: Vec::new(), - prune_dangling_copper: true, + ..Default::default() }, ); // The shipped default: negotiated congestion + validated push-shove. @@ -4620,7 +4769,7 @@ mod tests { use_push_shove: false, effort: 1.0, priority_nets: Vec::new(), - prune_dangling_copper: true, + ..Default::default() }, ); let default = route_all(&pcb, 0.25, &[]); diff --git a/crates/vcad-ecad-pcb/src/router/legalize.rs b/crates/vcad-ecad-pcb/src/router/legalize.rs index 719e4ea3a..da3f5dcc0 100644 --- a/crates/vcad-ecad-pcb/src/router/legalize.rs +++ b/crates/vcad-ecad-pcb/src/router/legalize.rs @@ -24,6 +24,14 @@ //! invariant they enforce together: an autoroute pass on a clean placement //! never introduces `HoleToHole` or `SameNetBypass` violations of its own //! making, and never returns copper connected to nothing. +//! +//! Demotion is per *net*, which is the right granularity for a routed net whose +//! copper is one connected path — but not for a plane-stitched power net, whose +//! copper is dozens of independent pad→plane vias. There, one bad drill would +//! strip every stitch and leave the pour connected to nothing (measured on the +//! moteus fixture: a single hole conflict cost all 32 GND stitches). A stitch +//! via is therefore dropped on its own, taking only its dog-bone stub with it; +//! the pad it served is then honestly reported by the DRC's `UnstitchedPad`. use std::collections::BTreeSet; @@ -54,6 +62,9 @@ pub(super) struct LegalizeReport { /// hole-to-hole illegal after merging (fail-closed: better unrouted than /// unmanufacturable). Each entry carries the position of the offending via. pub demoted: Vec<(String, Vec2)>, + /// Plane-stitch vias dropped individually (with their dog-bone stub) rather + /// than demoting the whole net. Each entry is the via position. + pub dropped_stitches: Vec, } /// Legalize the router's flattened output in place. See the module docs. @@ -61,6 +72,7 @@ pub(super) fn legalize( pcb: &Pcb, traces: &mut Vec, vias: &mut Vec, + stitch_vias: &[Vec2], ) -> LegalizeReport { let mut report = LegalizeReport::default(); if traces.is_empty() && vias.is_empty() { @@ -84,10 +96,22 @@ pub(super) fn legalize( DrcRuleType::HoleToHole => { // Only act when one of the holes is a via we placed; a // pad-vs-pad conflict is the placement's, not ours. - let Some(net) = new_via_net_at_holes(v, vias) else { + let Some(idx) = new_via_at_holes(v, vias) else { continue; }; - demote_net(&net, v.position, traces, vias, &mut report); + // A plane stitch is an independent pad→plane connection: + // drop just that via (and its dog-bone stub) so the net's + // other stitches survive. Anything else is part of a routed + // path, where half a path is worse than none. + if stitch_vias + .iter() + .any(|p| d2(*p, vias[idx].position) <= POS_EPS * POS_EPS) + { + drop_stitch_via(idx, traces, vias, &mut report); + } else { + let net = vias[idx].net.clone(); + demote_net(&net, v.position, traces, vias, &mut report); + } progressed = true; break; // copper changed — re-judge from scratch } @@ -398,16 +422,34 @@ fn new_copper_bbox(traces: &[RoutedTrace], vias: &[RoutedVia]) -> (Vec2, Vec2) { /// two hole centers, so membership is tested against both actual hole centers /// via the message-independent geometry: a new via whose center is within its /// pad diameter of the reported midpoint and whose hole pair distance matches. -fn new_via_net_at_holes(v: &DrcViolation, vias: &[RoutedVia]) -> Option { +fn new_via_at_holes(v: &DrcViolation, vias: &[RoutedVia]) -> Option { // The midpoint sits between the two holes; each hole center is within // (center distance)/2 of it. Center distance = actual + r_a + r_b, and all // router drills are sub-millimeter, so a generous radius bound suffices. let reach = (v.actual.abs() + 2.0).max(2.0); vias.iter() - .filter(|via| d2(via.position, v.position).sqrt() <= reach) - .map(|via| (d2(via.position, v.position).sqrt(), via.net.clone())) - .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(_, net)| net) + .enumerate() + .filter(|(_, via)| d2(via.position, v.position).sqrt() <= reach) + .min_by(|a, b| d2(a.1.position, v.position).total_cmp(&d2(b.1.position, v.position))) + .map(|(i, _)| i) +} + +/// Drop one plane-stitch via and the dog-bone stub that fed it: any same-net +/// trace with an endpoint on the via is that stub, and without the via it is +/// copper leading nowhere. +fn drop_stitch_via( + idx: usize, + traces: &mut Vec, + vias: &mut Vec, + report: &mut LegalizeReport, +) { + let via = vias.remove(idx); + traces.retain(|t| { + t.net != via.net + || (d2(t.start, via.position) > POS_EPS * POS_EPS + && d2(t.end, via.position) > POS_EPS * POS_EPS) + }); + report.dropped_stitches.push(via.position); } /// Fail-closed: strip every piece of new copper on `net` and record the @@ -569,7 +611,7 @@ mod tests { let pcb = board(); let mut traces = vec![rtrace((5.0, 5.0), (10.0, 10.0), "A", PcbLayer::FCu)]; let mut vias = vec![rvia(10.0, 10.0, "A"), rvia(10.0, 10.0, "A")]; - let report = legalize(&pcb, &mut traces, &mut vias); + let report = legalize(&pcb, &mut traces, &mut vias, &[]); assert_eq!(report.merged_vias, 1); assert_eq!(vias.len(), 1); assert!(report.demoted.is_empty()); @@ -586,7 +628,7 @@ mod tests { rtrace((10.3, 10.0), (20.0, 10.0), "A", PcbLayer::BCu), ]; let mut vias = vec![rvia(10.0, 10.0, "A"), rvia(10.3, 10.0, "A")]; - let report = legalize(&pcb, &mut traces, &mut vias); + let report = legalize(&pcb, &mut traces, &mut vias, &[]); assert_eq!(report.merged_vias, 1); assert_eq!(vias.len(), 1); // The BCu trace's end at the dropped via must still reach the survivor: @@ -624,7 +666,7 @@ mod tests { }); let mut traces = vec![rtrace((10.3, 10.0), (20.0, 10.0), "A", PcbLayer::FCu)]; let mut vias = vec![rvia(10.3, 10.0, "A")]; - let report = legalize(&pcb, &mut traces, &mut vias); + let report = legalize(&pcb, &mut traces, &mut vias, &[]); assert_eq!(report.demoted.len(), 1); assert_eq!(report.demoted[0].0, "A"); assert!(vias.is_empty() && traces.is_empty()); @@ -718,6 +760,54 @@ mod tests { assert_eq!((traces.len(), vias.len()), (2, 1)); } + /// A plane-stitch via that lands hole-to-hole illegal is dropped on its own + /// — one bad drill must not cost a poured net every other stitch it has. + #[test] + fn illegal_plane_stitch_is_dropped_without_demoting_the_net() { + let mut pcb = board(); + pcb.vias.push(Via { + position: Vec2::new(10.0, 10.0), + diameter: 0.8, + drill: 0.4, + start_layer: PcbLayer::FCu, + end_layer: PcbLayer::BCu, + net: "B".into(), + source: Some(CopperSource::Manual), + }); + // Three GND plane stitches; only the first conflicts with the fixed + // hole above, and it carries a dog-bone stub. + let bad = Vec2::new(10.3, 10.0); + let mut vias = vec![ + rvia(bad.x, bad.y, "GND"), + rvia(30.0, 10.0, "GND"), + rvia(30.0, 20.0, "GND"), + ]; + let mut traces = vec![rtrace((9.0, 10.0), (bad.x, bad.y), "GND", PcbLayer::FCu)]; + let stitches = vec![bad, Vec2::new(30.0, 10.0), Vec2::new(30.0, 20.0)]; + let report = legalize(&pcb, &mut traces, &mut vias, &stitches); + + assert!( + report.demoted.is_empty(), + "the net must survive: {report:?}" + ); + assert_eq!(report.dropped_stitches.len(), 1); + assert_eq!(vias.len(), 2, "the other two stitches are kept"); + assert!( + vias.iter().all(|v| d2(v.position, bad) > POS_EPS * POS_EPS), + "the offending via is gone" + ); + assert!(traces.is_empty(), "its dog-bone stub goes with it"); + + // And the result really is hole-to-hole clean. + let candidate = candidate_pcb(&pcb, &traces, &vias); + assert!( + !crate::drc::check_drc(&candidate) + .iter() + .any(|v| v.rule == DrcRuleType::HoleToHole), + "legalized output must be hole-to-hole clean" + ); + } + /// A redundant same-net segment that brushes distant copper of its own net /// (SameNetBypass) is pruned when the net stays connected without it. #[test] @@ -745,7 +835,7 @@ mod tests { rtrace((8.0, 10.0), (5.0, 10.0), "A", PcbLayer::FCu), ]; let mut vias = vec![]; - let report = legalize(&pcb, &mut traces, &mut vias); + let report = legalize(&pcb, &mut traces, &mut vias, &[]); assert!(report.pruned_traces >= 1, "the noodle must be pruned"); // The result must be bypass-free. let candidate = candidate_pcb(&pcb, &traces, &vias); diff --git a/crates/vcad-kernel-wasm/src/lib.rs b/crates/vcad-kernel-wasm/src/lib.rs index 885ebd276..bd6459cb3 100644 --- a/crates/vcad-kernel-wasm/src/lib.rs +++ b/crates/vcad-kernel-wasm/src/lib.rs @@ -6290,10 +6290,16 @@ mod ecad_wasm { /// growing route session, with PathFinder-style negotiated congestion layered /// over the bounded rip-up, retrying on the back layer with transition vias /// that are probed on both layers before being committed. Returns - /// `{ traces, vias, routed_nets, unrouted_nets, diagnostics, routability }`; - /// every returned trace and via is clearance-legal, or the net is reported - /// unrouted (with a diagnostic naming the blockers, the congested region, and - /// a suggested layer/via) — the router never emits copper that shorts. + /// `{ traces, vias, zones, routed_nets, unrouted_nets, diagnostics, + /// routability }`; every returned trace and via is clearance-legal, or the + /// net is reported unrouted (with a diagnostic naming the blockers, the + /// congested region, and a suggested layer/via) — the router never emits + /// copper that shorts. + /// + /// `zones` are copper pours synthesized for high-current nets. **They must be + /// added to the board along with the traces and vias**: a poured net is + /// carried by its plane, so the router stitched its pads to the plane instead + /// of tracing them to each other. #[wasm_bindgen(js_name = ecadRouteAll)] pub fn ecad_route_all( pcb_json: &str, diff --git a/packages/engine/src/ecad.ts b/packages/engine/src/ecad.ts index 16c6722d0..1b37f5478 100644 --- a/packages/engine/src/ecad.ts +++ b/packages/engine/src/ecad.ts @@ -15,6 +15,7 @@ import type { DerivedPart, Receipt, ReceiptStatus, + Zone, } from "@vcad/ir"; // --------------------------------------------------------------------------- @@ -846,6 +847,13 @@ export interface UnroutedDiagnostic { export interface RouteAllResult { traces: RoutedTrace[]; vias: RoutedVia[]; + /** + * Copper pours synthesized for high-current nets. **Must be added to the + * board along with the traces and vias** — the routing assumes them: a poured + * net is carried by its plane, so its pads were stitched to the plane instead + * of traced to each other. Absent on kernels that predate pour synthesis. + */ + zones?: Zone[]; routed_nets: string[]; unrouted_nets: string[]; /** Per-unrouted-connection diagnostics; empty when fully routed. */ @@ -873,6 +881,7 @@ export async function routeAll( const empty: RouteAllResult = { traces: [], vias: [], + zones: [], routed_nets: [], unrouted_nets: [], diagnostics: [], diff --git a/packages/mcp/src/tools/ecad.ts b/packages/mcp/src/tools/ecad.ts index 094df74d2..c125f3dae 100644 --- a/packages/mcp/src/tools/ecad.ts +++ b/packages/mcp/src/tools/ecad.ts @@ -3322,6 +3322,7 @@ export async function routeNets(args: Record) { let tracesRemoved = 0; let viasRemoved = 0; + let zonesAdded = 0; if (targetNets.size > 0) { const beforeT = pcb.traces.length; const beforeV = pcb.vias.length; @@ -3398,6 +3399,14 @@ export async function routeNets(args: Record) { }; const applyRoute = (result: Awaited>) => { + // Synthesized copper pours first. The routing that follows *assumes* them: + // a poured net is carried by its plane, so the kernel stitched its pads to + // the plane instead of tracing them to each other. Dropping them here would + // leave those nets connected to nothing. + for (const z of result.zones ?? []) { + pcb.zones.push(structuredClone(z)); + zonesAdded++; + } for (const t of result.traces) { pcb.traces.push({ start: { x: t.start.x, y: t.start.y }, @@ -3674,6 +3683,9 @@ export async function routeNets(args: Record) { nets_routed: routedNets.size, routability, traces_added: tracesAdded, + // Copper pours the router synthesized for high-current nets: those + // nets are now carried by a plane and stitched to it, not traced. + ...(zonesAdded > 0 ? { zones_added: zonesAdded } : {}), // Copper hygiene: re-routing rips the prior route up first, so a // re-route reports both what it removed and what it laid — `added` // alone reads like monotonic growth even when copper is being From b6593419b6858703d94fcbec8bb4bc225ed5fdfc Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 10:16:59 -0400 Subject: [PATCH 2/3] fix(mecheval): pin the grader to phyz's penalty contact solver phyz 0.3 deprecated `contact_forces_implicit`, which turned `cargo clippy -- -D warnings` into a hard error and took main (and every open PR) red. The deprecation points at the convex solve (`assemble` + `solve_contacts`), which couples contacts through the Delassus operator instead of resolving each in isolation. That is a different contact model, not a rename: adopting it would move every drift number `fit_physics` measures and silently re-grade the mecheval F-suite, whose task tolerances were baselined against the penalty path. So this pins the existing behaviour with `#[allow(deprecated)]` and records why, rather than swapping solvers inside a CI fix. Migrating is worth doing on its own, with the F-suite re-baselined against the new numbers. Co-Authored-By: Claude Opus 5 --- mecheval/graders/src/fit_physics.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mecheval/graders/src/fit_physics.rs b/mecheval/graders/src/fit_physics.rs index 04f746f43..76aa7d2c3 100644 --- a/mecheval/graders/src/fit_physics.rs +++ b/mecheval/graders/src/fit_physics.rs @@ -52,6 +52,14 @@ use crate::blob::CheckOutcome; use crate::fit::HostGeometry; use phyz::collision::{epa_penetration_rot, gjk_distance_rot, sweep_and_prune, Collision, AABB}; +// phyz 0.3 deprecates the penalty solver in favour of the convex solve +// (`assemble` + `solve_contacts`), which couples contacts through the +// Delassus operator instead of treating each in isolation. That is a +// different contact model, not a drop-in rename: switching it would move +// every drift number this grader measures and silently re-grade the mecheval +// F-suite. Kept deliberately on the penalty path until that migration is done +// and re-baselined against the task tolerances. +#[allow(deprecated)] use phyz::contact::contact_forces_implicit; use phyz::math::{Mat3, SpatialInertia, SpatialTransform, SpatialVec, Vec3}; use phyz::model::ModelBuilder; @@ -234,6 +242,7 @@ pub fn check_pull_force( /// for `duration_sec`, return the accessory's world-frame translation /// magnitude in meters. `external_world_force_n` is an optional constant /// force on the accessory, expressed in world frame, in newtons. +#[allow(deprecated)] fn simulate_drop( candidate: &Solid, host: &HostGeometry, From a15d9fd0a2899edec9c04cf8c982251da58f2623 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 10:33:22 -0400 Subject: [PATCH 3/3] fix(fabprep): accept the keep-best restore in the wandering-loop test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a_wandering_loop_never_returns_a_worse_board_than_it_found` has been failing on main since the fix loop learned to restore the best board it saw. The test's contract is its name: however badly the loop wanders, the board it hands back must never be worse than the best one it found. The assertion encoded one particular way of honouring that — regress, then name the regression — and required `connectivity.regression() > 0`. With the restore in place the loop no longer regresses at all (0 on arrival, 0 on completion) and fails closed with "the fix loop stopped improving ... the best board the loop saw was restored". That satisfies the contract more strongly than the assertion it trips. So the assertion now allows either shape — no regression, or a regression that is named — and still forbids the one thing that matters: clearing violations by deleting copper and reporting it as progress. The added `blocker.is_some()` check keeps a silent pass from slipping through the weaker predicate. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-fabprep/src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/vcad-ecad-fabprep/src/lib.rs b/crates/vcad-ecad-fabprep/src/lib.rs index 10b4e6009..a5bedffe9 100644 --- a/crates/vcad-ecad-fabprep/src/lib.rs +++ b/crates/vcad-ecad-fabprep/src/lib.rs @@ -746,9 +746,15 @@ mod tests { .report; assert!(!report.converged, "an unroutable short must fail closed"); + assert!(report.blocker.is_some(), "a failed run must name a blocker"); + // Two acceptable shapes, and the loop may reach either depending on how + // far it wanders: it restored the best board it saw, so no copper was + // lost at all (`regression() == 0`), or it did hand back a sparser board + // and said so. What is never allowed is regressing silently — clearing + // violations by deleting copper and reporting that as progress. assert!( - report.connectivity.regression() > 0 - && report + report.connectivity.regression() == 0 + || report .blocker .as_deref() .is_some_and(|b| b.contains("unconnected")),