Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog/entries/2026-07-24-router-copper-pour-synthesis.json
Original file line number Diff line number Diff line change
@@ -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"]
}
10 changes: 8 additions & 2 deletions crates/vcad-ecad-fabprep/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
30 changes: 30 additions & 0 deletions crates/vcad-ecad-pcb/examples/cm5_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -152,6 +159,7 @@ fn main() {
&RouteOptions {
effort,
priority_nets: priority.clone(),
pour_policy,
..Default::default()
},
);
Expand All @@ -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::<Vec<_>>()
.join(", ")
);
}
println!(
"score: routability {:.3}, via ratio {:.2}x human, length ratio {:.2}x human, {:.1}s",
r.routability,
Expand All @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions crates/vcad-ecad-pcb/examples/pour_report.rs
Original file line number Diff line number Diff line change
@@ -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 <board.kicad_pcb|.brd|.pcb.json>");
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::<f64>(),
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()
}
Loading
Loading