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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ typst/vcad/vcad.wasm
typst/vcad/examples/*.pdf
typst/vcad/examples/*.png
npm-dist/
.scratch/
10 changes: 10 additions & 0 deletions changelog/entries/2026-07-25-router-diff-pair-si.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "2026-07-25-router-diff-pair-si",
"version": "0.9.4",
"date": "2026-07-25",
"category": "fix",
"title": "Differential pairs route coupled and length-matched",
"summary": "Coupled pairs can now escape a BGA on an inner layer instead of failing outright, USB DP/DM pairs are recognized, and a finishing pass re-couples, descends and phase-compensates each pair so intra-pair skew lands inside the signal-integrity receipt's bounds.",
"features": ["pcb", "autorouter", "diff-pairs", "signal-integrity", "drc"],
"mcpTools": ["route_nets", "route_diff_pair", "run_drc", "build_receipt"]
}
75 changes: 43 additions & 32 deletions crates/vcad-ecad-pcb/examples/cm5_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,21 +186,51 @@ fn main() {
eprintln!("unrouted {}: {}", d.net, d.reason);
}

// Apply the routed copper to the board, then run the SI finishing pass
// (reroute-then-descend). Both of its stages are non-regressive and
// 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.
for t in &r.traces {
pcb.traces.push(Trace {
start: t.start,
end: t.end,
width: t.width,
layer: t.layer,
net: t.net.clone(),
source: None,
});
}
for v in &r.vias {
pcb.vias.push(Via {
position: v.position,
diameter: pcb.rules.default_rules.via_diameter,
drill: pcb.rules.default_rules.via_drill,
start_layer: v.start_layer,
end_layer: v.end_layer,
net: v.net.clone(),
source: None,
});
}
if std::env::var("VCAD_SI_FINISH").as_deref() != Ok("0") {
let t1 = Instant::now();
let fin = vcad_ecad_pcb::router::si_finish(&mut pcb, 2_000_000, 2000);
println!(
"si-finish: {}/{} pairs re-coupled, {}/{} descended ({} rejected), {:.1}s",
fin.polished,
fin.polish_attempted,
fin.descent.tuned,
fin.descent.attempted,
fin.descent.rejected,
t1.elapsed().as_secs_f64(),
);
}

// SI scoreboard: skew per length-match group and per differential pair,
// measured on the routed copper. This is the gap the meander tuner must
// close — and the number the human board is matched to within microns.
{
let mut with_routes = pcb.clone();
for t in &r.traces {
with_routes.traces.push(Trace {
start: t.start,
end: t.end,
width: t.width,
layer: t.layer,
net: t.net.clone(),
source: None,
});
}
let with_routes = pcb.clone();
for (gname, members) in &classifier.match_groups {
let lens: Vec<(f64, &str)> = members
.iter()
Expand Down Expand Up @@ -235,28 +265,9 @@ fn main() {
}

// Save the routed board for rendering / inspection without re-routing.
// The copper is already applied to `pcb` above (the SI finishing pass
// rewrites some of it, so it has to be applied before that runs).
if let Some(out) = out_json {
for t in &r.traces {
pcb.traces.push(Trace {
start: t.start,
end: t.end,
width: t.width,
layer: t.layer,
net: t.net.clone(),
source: None,
});
}
for v in &r.vias {
pcb.vias.push(Via {
position: v.position,
diameter: pcb.rules.default_rules.via_diameter,
drill: pcb.rules.default_rules.via_drill,
start_layer: v.start_layer,
end_layer: v.end_layer,
net: v.net.clone(),
source: None,
});
}
std::fs::write(&out, serde_json::to_string(&pcb).expect("serialize board"))
.expect("write routed board json");
eprintln!("wrote {out}");
Expand Down
77 changes: 77 additions & 0 deletions crates/vcad-ecad-pcb/examples/si_census.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! Pair bail census: run the coupled-pair construction stage on a board with
//! all copper stripped and histogram why each pair did or did not couple.
//!
//! The full CM5 route takes hours; the pair stage sees a near-empty board in
//! round 0 regardless, so censusing it standalone measures the same geometry
//! in seconds. Kill the dominant bail mode, re-census, repeat.
//!
//! ```bash
//! cargo run --release -p vcad-ecad-pcb --example si_census -- board.kicad_pcb [expansions]
//! ```

use vcad_ecad_pcb::router::census_pairs;
use vcad_ecad_symbols::parse_kicad_pcb;
use vcad_ir::ecad::Pcb;

fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn"))
.format_timestamp_millis()
.init();
let mut args = std::env::args().skip(1);
let path = args.next().expect("usage: si_census <board> [expansions]");
let expansions: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(400_000);

let text = std::fs::read_to_string(&path).expect("read board");
let pcb: Pcb = if path.ends_with(".json") {
serde_json::from_str(&text).expect("parse pcb json")
} else {
parse_kicad_pcb(&text).expect("parse kicad_pcb")
};

let t0 = std::time::Instant::now();
let census = census_pairs(&pcb, expansions);
let elapsed = t0.elapsed();

let total = census.rows.len();
println!(
"pair census: {total} pairs, {} coupled, {} coupled >=0.5 fraction ({:.1}s)",
census.coupled(),
census.coupled_above(0.5),
elapsed.as_secs_f64()
);
println!("\nbail histogram:");
for (bail, n) in census.histogram() {
println!(" {n:3} {}", bail.slug());
}

// Successes with a WEAK coupled fraction are the silent failure mode: the
// pair constructed, so the stage counts it, but the receipt claim does not.
let mut weak: Vec<&vcad_ecad_pcb::router::PairCensusRow> = census
.rows
.iter()
.filter(|r| r.bail.is_none() && r.coupled_fraction < 0.5)
.collect();
weak.sort_by(|a, b| a.coupled_fraction.partial_cmp(&b.coupled_fraction).unwrap());
if !weak.is_empty() {
println!("\ncoupled but BELOW 0.5 fraction ({} pairs):", weak.len());
for r in weak.iter().take(20) {
println!(
" {:.3} span {:6.2}mm {}",
r.coupled_fraction, r.span_mm, r.net_p
);
}
}

println!("\nbailed pairs (span, reason):");
let mut bailed: Vec<&vcad_ecad_pcb::router::PairCensusRow> =
census.rows.iter().filter(|r| r.bail.is_some()).collect();
bailed.sort_by(|a, b| a.span_mm.partial_cmp(&b.span_mm).unwrap());
for r in bailed.iter().take(60) {
println!(
" {:6.2}mm {:26} {}",
r.span_mm,
r.bail.unwrap().slug(),
r.net_p
);
}
}
Loading