From ea0a7e870c506b402fb63c0752c076e5fbc2639c Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 11:06:18 -0400 Subject: [PATCH 1/5] fix(router): couple short pairs with a direct centerline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pair whose pads sit a few mm apart has no room for the lead-inset phantom centerline: the lead eats both ends and a ~1.8mm span keeps ~0.9mm to search, so both ladders fail and the pair falls back to two independent singles. Those singles land on DIFFERENT layers, and coupled_fraction only counts twin copper on the same layer — so the pair scores exactly 0.0 and pins the SI receipt's min_pair_coupled_fraction at zero however well the rest of the board routes (M7 named this: the /HS.* group). Such a pair does not need a search. Route it directly: one straight, lead-free centerline on the best layer all four pads share, offset into the two legs by the same realize_legs every other centerline uses. Four point probes choose the layer; the authoritative check is still validate_and_commit, so this adds no way to commit copper the oracle has not seen. CM5 pair census: 44 of 49 coupled, up from 39, and the centerline-search bail class — M7's dominant mode at 5-6 pairs — is now empty. The 5 that remain are long pairs failing at their connectors, a different mode. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/pair.rs | 187 +++++++++++++++++++++++- 1 file changed, 185 insertions(+), 2 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/pair.rs b/crates/vcad-ecad-pcb/src/router/pair.rs index 260877bb..c5912218 100644 --- a/crates/vcad-ecad-pcb/src/router/pair.rs +++ b/crates/vcad-ecad-pcb/src/router/pair.rs @@ -287,10 +287,23 @@ pub(super) fn try_route_pair_reason( let mid_to = Vec2::new((to.x + p_to.x) / 2.0, (to.y + p_to.y) / 2.0); let span = dist(mid_from, mid_to); let lead = (fat_w + clearance + w).min(span * 0.25); - if span < 4.0 * lead.max(0.1) { + // Below SHORT_PAIR_SPAN_MM the lead inset is the whole problem: a ~1.8mm + // pad-to-pad pair keeps only ~0.9mm of searchable centerline, both ladders + // fail, and the pair falls back to two singles whose legs land on + // DIFFERENT layers — scoring coupled_fraction exactly 0.0 and pinning the + // receipt's pair claim at zero however well the rest of the board routes + // (M7: the `/HS.*` group). Such a pair does not need a search at all; it + // needs the straight centerline it was always going to have. Route it + // directly below, so only spans too long for that path still bail here. + if span < 4.0 * lead.max(0.1) && span > SHORT_PAIR_SPAN_MM { log::debug!("pair: {net}/{partner}: span {span:.2}mm too short for coupled routing"); return Err(PairBail::SpanTooShort); } + if span < 2.0 * half_sep { + // Degenerate: the twin legs would be further apart than the span. + log::debug!("pair: {net}/{partner}: span {span:.2}mm below one pair separation"); + return Err(PairBail::SpanTooShort); + } let dir = { let d = mid_to - mid_from; d.scale(1.0 / span) @@ -361,6 +374,30 @@ pub(super) fn try_route_pair_reason( // how a human escapes a BGA with a pair: singles in the field, coupled // in the open. let budget = max_expansions.max(100_000); + // Short pairs first: a straight, lead-free centerline on a layer all four + // pads share. Exact and probe-validated, so it costs four point probes + // rather than a maze search, and it keeps both legs on ONE layer — which + // is what `coupled_fraction` measures. + let direct = if span <= SHORT_PAIR_SPAN_MM { + direct_centerline( + session, + pcb, + net, + &partner, + (mid_from, mid_to), + [from, to, p_from, p_to], + half_sep, + via_d / 2.0, + clearance, + w, + &copper, + ) + } else { + None + }; + if direct.is_some() { + log::debug!("pair: {net}/{partner}: short span {span:.2}mm — direct coupled centerline"); + } let mut found = None; let usable_span = span - 2.0 * lead; // MEASURED neck-down (census 18). The fixed table below guesses how far @@ -433,6 +470,9 @@ pub(super) fn try_route_pair_reason( (16.0, 16.0), ]); for (r_from, r_to) in rungs { + if direct.is_some() { + break; + } let usable = span - 2.0 * lead; if r_from + r_to >= usable - 1.0 { continue; @@ -492,7 +532,7 @@ pub(super) fn try_route_pair_reason( // the amount of neck-down is measured rather than drawn from a 15-rung // table. Widths descend so a wider (better-coupled) corridor always wins // when one exists; the best trimmed run across the ladder is kept. - let mut center: Option = found.map(|r| r.segments); + let mut center: Option = direct.or_else(|| found.map(|r| r.segments)); if center.is_none() { // Minimum worthwhile coupling: below this the pair is better off as // two singles, so we fall through rather than commit a token stub. @@ -911,6 +951,81 @@ fn longest_coupled_window( /// ±`via_off` perpendicular at each layer transition (two drills per centerline /// via, one per leg) and connector jogs where the via offset exceeds the leg /// offset. Leg A is the +offset side, leg B the −offset side. +/// Spans at or below this route as short pairs: one straight centerline, no +/// lead inset, no search. Above it the phantom corridor is the right tool. +const SHORT_PAIR_SPAN_MM: f64 = 4.0; + +/// A straight, lead-free centerline for a short pair, on the best layer that +/// admits both offset legs. +/// +/// Layer order prefers the layers all four pads share, so the pair needs no +/// vias at all (which is also what keeps `vias_per_si_net` low); the rest of +/// the stack follows as a fallback for pads on different layers. +/// +/// The returned centerline is a *proposal*: both legs are probed here only to +/// choose the layer. The authoritative check is the same `validate_and_commit` +/// every other centerline goes through — this path adds no way to commit +/// copper the oracle has not seen. +#[allow(clippy::too_many_arguments)] +fn direct_centerline( + session: &RouteSession, + pcb: &Pcb, + net: &str, + partner: &str, + (mid_from, mid_to): (Vec2, Vec2), + pads: [Vec2; 4], + half_sep: f64, + via_r: f64, + clearance: f64, + w: f64, + copper: &[PcbLayer], +) -> Option { + let [from, to, p_from, p_to] = pads; + let pad_layers = [ + pad_layers_at(pcb, net, from), + pad_layers_at(pcb, net, to), + pad_layers_at(pcb, partner, p_from), + pad_layers_at(pcb, partner, p_to), + ]; + let shared = |l: PcbLayer| pad_layers.iter().all(|ls| ls.is_empty() || ls.contains(&l)); + let order = copper + .iter() + .filter(|&&l| shared(l)) + .chain(copper.iter().filter(|&&l| !shared(l))); + + for &layer in order { + let center: Centerline = vec![(mid_from, mid_to, layer)]; + let Some((leg_a, leg_b)) = realize_legs(¢er, half_sep, half_sep, via_r, clearance, w) + else { + continue; + }; + // Each leg must be legal for ONE of the two nets — the assignment + // itself is decided downstream by connector length. + let leg_legal = |leg: &Leg, as_net: &str| { + leg.segments.iter().all(|&(a, b, l)| { + session + .probe( + &CopperGeom::Segment { + a, + b, + half_w: w / 2.0, + }, + l, + as_net, + clearance, + ) + .legal + }) + }; + let straight = leg_legal(&leg_a, net) && leg_legal(&leg_b, partner); + let crossed = leg_legal(&leg_a, partner) && leg_legal(&leg_b, net); + if straight || crossed { + return Some(center); + } + } + None +} + fn realize_legs( center: &[(Vec2, Vec2, PcbLayer)], half_sep: f64, @@ -1761,6 +1876,74 @@ mod tests { pcb } + /// A pair whose pads sit ~2 mm apart — too short for a lead-inset phantom + /// centerline, which is exactly the `/HS.*` case that pinned the SI + /// receipt's `min_pair_coupled_fraction` at 0.000: routed as two singles + /// the legs land on different layers and score zero coupling however well + /// the rest of the board routes. + #[test] + fn routes_short_span_pair_coupled_on_one_layer() { + let mut pcb = pair_board(); + // Move the far connector to 2 mm from the near one. + pcb.footprints[1].position = Vec2::new(7.0, 15.0); + let mut session = RouteSession::from_pcb(&pcb); + let cong = Congestion::new(&pcb.outline.vertices); + let mut placed = Vec::new(); + let (p, n) = try_route_pair( + &mut session, + &pcb, + 0.25, + "/ETH.2_P", + Vec2::new(5.0, 15.325), + Vec2::new(7.0, 15.325), + &mut placed, + &cong, + 200_000, + ) + .expect("a 2mm pad-to-pad pair must still route coupled"); + assert!(!p.segments.is_empty() && !n.segments.is_empty()); + // The point of the short-pair path: both legs on ONE layer, which is + // what `si_claims::coupled_fraction` measures (it only counts twin + // copper on the SAME layer). + let layers = |pl: &Placed| -> Vec { + let mut ls: Vec<_> = pl.segments.iter().map(|(_, _, l)| *l).collect(); + ls.sort_by_key(|l| format!("{l:?}")); + ls.dedup(); + ls + }; + assert_eq!( + layers(&p), + layers(&n), + "short pair legs must share their layer, else coupled_fraction is 0" + ); + // And the coupled run is measurable, not a token stub. + let frac = crate::router::si_claims::coupled_fraction( + &{ + let mut b = pcb.clone(); + for pl in [&p, &n] { + for (a, e, l) in &pl.segments { + b.traces.push(Trace { + start: *a, + end: *e, + width: pl.width, + layer: *l, + net: pl.net.clone(), + source: None, + }); + } + } + b + }, + "/ETH.2_P", + "/ETH.2_N", + 0.45 + 0.1, + ); + assert!( + frac >= 0.5, + "short pair should be at least half coupled, got {frac}" + ); + } + #[test] fn routes_open_board_pair_coupled() { let pcb = pair_board(); From 7de1847ba54ce82590a2374a2e8db3e8d44f1e52 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 11:46:36 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(ecad):=20KiCad=20pad=20angles=20are=20a?= =?UTF-8?q?bsolute=20=E2=80=94=20stop=20double-counting=20footprint=20rota?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KiCad stores a pad's angle ABSOLUTELY: it already includes the footprint's orientation. Measured on the CM5 fixture, the pad angle tracks the footprint exactly — fp_rot -90 -> pad_rot 270 (798 pads), 45 -> 45, 90 -> 90 (285), 135 -> 135, 180 -> 180. vcad's IR stores it RELATIVE, because all eleven geometry consumers (DRC, the router's spatial index, DFM, pour synthesis, render, the 3D mesh, eval) compose fp.rotation + pad.rotation. The importer stored the absolute angle, so every rotated footprint had its footprint rotation counted twice. A TQFN's 0.25 x 0.875mm pads at 0.5mm pitch came out turned 90 degrees — long axis ALONG the pitch — so neighbouring pads overlapped. That is not a subtle error: it invented copper. Fixed at the import boundary (reader subtracts, writer re-composes) so all eleven consumers become correct without touching them, pinned by a round-trip test. Measured on the CM5 fixture, copper stripped: short/clearance 980 -> 311 Clearance 74 -> 23 So 648 of the violations this project has been attributing to the reverse-engineered source were ours. Every DRC-delta claim measured against that baseline needs re-measuring. Pair coupling census: 39 -> 47 of 49. The four /ETH.* pairs that pinned both broken SI claims couple now that their pads no longer overlap. Also here, both found while chasing those bails and each worth a pair on its own (46 -> 47 together): - short pairs get a direct, lead-free centerline (a ~1.8mm pad-to-pad span has no room for the lead inset, so it fell back to singles on different layers and scored exactly 0.0 coupling); - a pad connector retreats along the centerline and escalates its search window, because a pad inside a fine-pitch field must escape laterally before it can drop a via, and a window sized from a 0.8mm hop cannot contain that. Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/pair.rs | 264 +++++++++++++------- crates/vcad-ecad-symbols/src/kicad_pcb.rs | 28 ++- crates/vcad-ecad-symbols/src/kicad_write.rs | 49 +++- 3 files changed, 249 insertions(+), 92 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/pair.rs b/crates/vcad-ecad-pcb/src/router/pair.rs index c5912218..2e09eed8 100644 --- a/crates/vcad-ecad-pcb/src/router/pair.rs +++ b/crates/vcad-ecad-pcb/src/router/pair.rs @@ -626,16 +626,6 @@ pub(super) fn try_route_pair_reason( return Err(PairBail::CenterlineSearch); }; - // Realize the two legs from the centerline. - let (leg_a, leg_b) = match realize_legs(¢er, half_sep, via_off, via_d / 2.0, clearance, w) { - Some(l) => l, - None => { - log::debug!("pair: {net}/{partner}: degenerate centerline — bailing"); - restore(session, placed, ripped); - return Err(PairBail::DegenerateCenterline); - } - }; - // Leg → net assignment: the one whose pad connectors are shortest (the // non-crossing assignment — crossing connectors are strictly longer). // @@ -645,13 +635,6 @@ pub(super) fn try_route_pair_reason( // are maze routes that detour around whatever is in the way, so the // ranking it produces is mostly noise. let cost = |leg: &Leg, s: Vec2, e: Vec2| dist(s, leg.first) + dist(e, leg.last); - let a_mine = cost(&leg_a, from, to) + cost(&leg_b, p_from, p_to) - <= cost(&leg_b, from, to) + cost(&leg_a, p_from, p_to); - let (mine, theirs) = if a_mine { - (leg_a, leg_b) - } else { - (leg_b, leg_a) - }; // Connector from a pad to a leg end: straight when short, maze-routed at // single width when the crow-flight would thread a pin field (the second @@ -697,11 +680,6 @@ pub(super) fn try_route_pair_reason( return Some((vec![(from, to, to_layer)], vec![])); } } - let margin = 4.0 + w; - let window = ( - Vec2::new(from.x.min(to.x) - margin, from.y.min(to.y) - margin), - Vec2::new(from.x.max(to.x) + margin, from.y.max(to.y) + margin), - ); // Multi-goal attachment: the connector may terminate anywhere along // the leg's copper, not only at its endpoint — an offset leg end can // sit against a neighbouring pad where no legal approach exists. @@ -735,39 +713,59 @@ pub(super) fn try_route_pair_reason( on_board } }; - let r = route_net_maze3d( - session, - &pcb.outline.vertices, - &copper, - net, - from, - &from_layers, - to, - &[to_layer], - nw, - via_d, - Some(cong), - 120_000, - 0.5, - Some(window), - &goals, - &[], - true, - ); - if r.success { - Some((r.segments, r.vias)) - } else { - log::debug!( - "pair-connector: {net} {:.2}mm ({:.2},{:.2})->({:.2},{:.2}) layer {:?} failed", - dist(from, to), - from.x, - from.y, - to.x, - to.y, - to_layer + // Escalating window. The hop itself is sub-millimetre, but its LENGTH + // is the wrong scale to size the search by: a pad inside a fine-pitch + // field can only reach another layer by escaping the field laterally + // first and dropping its via outside — the channels between 1.37mm + // pads on a 1.7mm pitch are 0.33mm, wide enough for the 0.08mm neck + // but not for a 0.21mm via plus clearance. A window drawn 4mm around + // a 0.8mm hop cannot contain that detour, so the connector failed + // while a perfectly good coupled corridor sat 0.8mm away (CM5: the + // last five pairs, all of them `/ETH.*` and `/PCIe.*` at U4). + for margin in [4.0 + w, 10.0 + w, 20.0 + w] { + let window = ( + Vec2::new(from.x.min(to.x) - margin, from.y.min(to.y) - margin), + Vec2::new(from.x.max(to.x) + margin, from.y.max(to.y) + margin), ); - None + let r = route_net_maze3d( + session, + &pcb.outline.vertices, + &copper, + net, + from, + &from_layers, + to, + &[to_layer], + nw, + via_d, + Some(cong), + 120_000, + 0.5, + Some(window), + &goals, + &[], + true, + ); + if r.success { + if margin > 4.0 + w + 1e-9 { + log::debug!( + "pair-connector: {net} reached at {margin:.0}mm window ({:.2}mm hop)", + dist(from, to) + ); + } + return Some((r.segments, r.vias)); + } } + log::debug!( + "pair-connector: {net} {:.2}mm ({:.2},{:.2})->({:.2},{:.2}) layer {:?} failed", + dist(from, to), + from.x, + from.y, + to.x, + to.y, + to_layer + ); + None }; // Ordering (census 9's lesson): commit BOTH legs first — they are // parallel and disjoint by construction — then route the four pad @@ -786,38 +784,8 @@ pub(super) fn try_route_pair_reason( thin_width: nw, } }; - let Some(mut placed_mine) = - validate_and_commit(session, pcb, leg_cand(&mine, net, from, to), placed) - else { - log::debug!("pair: {net}/{partner}: leg 1 failed validation"); - restore(session, placed, ripped); - return Err(PairBail::LegValidation); - }; - let Some(mut placed_theirs) = validate_and_commit( - session, - pcb, - leg_cand(&theirs, &partner, p_from, p_to), - placed, - ) else { - log::debug!("pair: {net}/{partner}: leg 2 failed validation — rolled back leg 1"); - for &sp in &placed_mine.spans { - session.remove(sp); - } - restore(session, placed, ripped); - return Err(PairBail::LegValidation); - }; // Connectors, all four against both committed legs. Committed as thin // copper directly into each leg's Placed (stubs channel). - let rollback_all = |session: &mut RouteSession, - placed: &mut Vec, - a: &Placed, - b: &Placed, - ripped: Vec| { - for &sp in a.spans.iter().chain(b.spans.iter()) { - session.remove(sp); - } - restore(session, placed, ripped); - }; let attach = |session: &mut RouteSession, pl: &mut Placed, leg: &Leg, @@ -868,14 +836,94 @@ pub(super) fn try_route_pair_reason( pl.via_pts.extend(tv); true }; - if !attach(session, &mut placed_mine, &mine, from, to) - || !attach(session, &mut placed_theirs, &theirs, p_from, p_to) - { - rollback_all(session, placed, &placed_mine, &placed_theirs, ripped); - return Err(PairBail::Connector); + // Connector retreat. A coupled corridor can be perfectly good and still be + // unreachable: the leg end lands inside a through-hole field on a deep + // inner layer, and the sub-millimetre hop from the pad to it has no legal + // path (CM5 census: every remaining bail was a *head* connector of 0.6-1.2mm + // into In4Cu/BCu/In8Cu under the 100-pin connector). Giving up there throws + // away the whole coupled run over its last half-millimetre. + // + // So trim the centerline back from the end that failed and try again: a + // shorter coupled run whose ends sit in open copper, with slightly longer + // neck-down connectors covering the difference. Rungs are asymmetric + // because the two ends fail independently, and each is small next to the + // spans this rescues (0.6-3mm off a 21-33mm pair). + const RETREAT_RUNGS: [(f64, f64); 7] = [ + (0.0, 0.0), + (0.6, 0.0), + (0.0, 0.6), + (1.5, 0.0), + (0.0, 1.5), + (1.5, 1.5), + (3.0, 3.0), + ]; + let mut outcome: Option<(Placed, Placed, f64)> = None; + let mut last_bail = PairBail::Connector; + for (r_from, r_to) in RETREAT_RUNGS { + let Some(center_t) = trim_centerline(¢er, r_from, r_to) else { + continue; + }; + let Some((leg_a, leg_b)) = + realize_legs(¢er_t, half_sep, via_off, via_d / 2.0, clearance, w) + else { + last_bail = PairBail::DegenerateCenterline; + continue; + }; + let a_mine = cost(&leg_a, from, to) + cost(&leg_b, p_from, p_to) + <= cost(&leg_b, from, to) + cost(&leg_a, p_from, p_to); + let (mine, theirs) = if a_mine { + (leg_a, leg_b) + } else { + (leg_b, leg_a) + }; + // Ordering (census 9's lesson): commit BOTH legs first — they are + // parallel and disjoint by construction — then route the four pad + // connectors against the complete picture, so no connector can cut the + // coupled corridor before the other leg exists. + let Some(mut placed_mine) = + validate_and_commit(session, pcb, leg_cand(&mine, net, from, to), placed) + else { + log::debug!("pair: {net}/{partner}: leg 1 failed validation"); + last_bail = PairBail::LegValidation; + continue; + }; + let Some(mut placed_theirs) = validate_and_commit( + session, + pcb, + leg_cand(&theirs, &partner, p_from, p_to), + placed, + ) else { + log::debug!("pair: {net}/{partner}: leg 2 failed validation — rolled back leg 1"); + for &sp in &placed_mine.spans { + session.remove(sp); + } + last_bail = PairBail::LegValidation; + continue; + }; + if attach(session, &mut placed_mine, &mine, from, to) + && attach(session, &mut placed_theirs, &theirs, p_from, p_to) + { + if r_from + r_to > 0.0 { + log::debug!( + "pair: {net}/{partner}: connectors reached after {r_from}/{r_to}mm retreat" + ); + } + let len: f64 = center_t.iter().map(|(a, b, _)| dist(*a, *b)).sum(); + outcome = Some((placed_mine, placed_theirs, len)); + break; + } + // Connectors failed: drop this attempt's copper (keeping the ripped + // partner routes for the next rung) and retreat further. + for &sp in placed_mine.spans.iter().chain(placed_theirs.spans.iter()) { + session.remove(sp); + } + last_bail = PairBail::Connector; } + let Some((placed_mine, placed_theirs, center_len)) = outcome else { + restore(session, placed, ripped); + return Err(last_bail); + }; - let center_len: f64 = center.iter().map(|(a, b, _)| dist(*a, *b)).sum(); log::info!( "pair: routed {net} + {partner} coupled (centerline {:.1}mm, {} via pair(s), w={w} gap={gap})", center_len, @@ -884,6 +932,44 @@ pub(super) fn try_route_pair_reason( Ok((placed_mine, placed_theirs)) } +/// Shorten a centerline by `r_from` millimetres at its start and `r_to` at its +/// end, measured along the polyline. Returns `None` if the trim would leave +/// less than a via pitch of coupled run — at that point the pair is better off +/// as two singles than as a token stub. +fn trim_centerline(center: &Centerline, r_from: f64, r_to: f64) -> Option { + if r_from <= 0.0 && r_to <= 0.0 { + return Some(center.clone()); + } + let total: f64 = center.iter().map(|(a, b, _)| dist(*a, *b)).sum(); + const MIN_COUPLED_MM: f64 = 1.0; + if total - r_from - r_to < MIN_COUPLED_MM { + return None; + } + let mut out: Centerline = Vec::new(); + let mut walked = 0.0; + for &(a, b, l) in center { + let seg = dist(a, b); + if seg < 1e-9 { + continue; + } + let (s0, s1) = (walked, walked + seg); + walked = s1; + // Clip this segment to the surviving [r_from, total - r_to] interval. + let lo = r_from.max(s0); + let hi = (total - r_to).min(s1); + if hi - lo < 1e-9 { + continue; + } + let dir = (b - a).scale(1.0 / seg); + out.push((a + dir.scale(lo - s0), a + dir.scale(hi - s0), l)); + } + if out.is_empty() { + None + } else { + Some(out) + } +} + /// The longest contiguous run of `center` (as an inclusive segment index /// range) over which BOTH offset legs probe legal at the pair's leg width. /// diff --git a/crates/vcad-ecad-symbols/src/kicad_pcb.rs b/crates/vcad-ecad-symbols/src/kicad_pcb.rs index 17ecd4a6..d4d031f2 100644 --- a/crates/vcad-ecad-symbols/src/kicad_pcb.rs +++ b/crates/vcad-ecad-symbols/src/kicad_pcb.rs @@ -558,11 +558,27 @@ fn parse_footprint(node: &SExpr<'_>, net_map: &HashMap) -> Option pad_rot 270 on 798 pads, 45 -> 45, 90 -> 90, 135 -> 135). + // vcad's IR stores it RELATIVE to the footprint, because every consumer + // (DRC, the router's spatial index, DFM, pour synthesis, render, the 3D + // mesh) composes `fp.rotation + pad.rotation`. Storing the absolute angle + // double-counted the footprint rotation on every rotated part: a QFN's + // 0.25 x 0.875mm pads at 0.5mm pitch came out rotated 90 degrees off, so + // neighbouring pads OVERLAPPED — phantom shorts in DRC and a pin field the + // router could not escape. let pads: Vec = node .find_all("pad") .iter() .filter_map(|p| parse_pad(p, net_map)) + .map(|mut pad| { + pad.rotation = normalize_deg(pad.rotation - rotation); + pad + }) .collect(); // Parse graphics (fp_line, fp_circle, fp_rect, fp_arc) @@ -621,6 +637,16 @@ fn parse_footprint(node: &SExpr<'_>, net_map: &HashMap) -> Option f64 { + let r = a % 360.0; + if r < 0.0 { + r + 360.0 + } else { + r + } +} + fn parse_pad(node: &SExpr<'_>, net_map: &HashMap) -> Option { let children = node.children()?; // (pad "1" smd rect (at X Y) (size W H) (layers ...) (net N "name")) diff --git a/crates/vcad-ecad-symbols/src/kicad_write.rs b/crates/vcad-ecad-symbols/src/kicad_write.rs index afddda98..2864443e 100644 --- a/crates/vcad-ecad-symbols/src/kicad_write.rs +++ b/crates/vcad-ecad-symbols/src/kicad_write.rs @@ -607,12 +607,24 @@ fn write_pad(e: &mut Emitter, pad: &Pad, fp: &Footprint, nets: &NetTable) { 2, &format!("(pad {} {} {}", q(&pad.number), type_str, shape_str), ); - let at = if pad.rotation != 0.0 { + // KiCad's pad angle is ABSOLUTE (it includes the footprint's orientation); + // vcad's IR keeps it relative to the footprint, so compose on the way out. + // The reader performs the inverse, and `pad_rotation_round_trips` pins the + // pair together. + let abs_rot = { + let r = (fp.rotation + pad.rotation) % 360.0; + if r < 0.0 { + r + 360.0 + } else { + r + } + }; + let at = if abs_rot != 0.0 { format!( "(at {} {} {})", num(pad.position.x), num(pad.position.y), - num(pad.rotation) + num(abs_rot) ) } else { format!("(at {} {})", num(pad.position.x), num(pad.position.y)) @@ -2528,6 +2540,39 @@ mod tests { assert_eq!(exported, write_kicad_pcb(&pcb2)); } + /// KiCad's pad angle is absolute (it includes the footprint's rotation); + /// vcad's IR keeps it relative, because every geometry consumer composes + /// `fp.rotation + pad.rotation`. Writer and reader must be exact inverses, + /// or a rotated footprint's non-square pads come out turned by the + /// footprint's own angle — which on a 0.5mm-pitch QFN makes neighbouring + /// pads OVERLAP (this was real: it produced hundreds of phantom DRC shorts + /// on the CM5 fixture and a pin field the router could not escape). + #[test] + fn pad_rotation_round_trips_through_footprint_rotation() { + let mut pcb = sample_pcb(); + pcb.footprints[0].rotation = 90.0; + pcb.footprints[0].pads[0].rotation = 0.0; + pcb.footprints[0].pads[1].rotation = 45.0; + + let text = write_kicad_pcb(&pcb); + // On the wire the angles are absolute: 90 and 135. + assert!( + text.contains(" 90)"), + "absolute pad angle 90 must be emitted" + ); + assert!( + text.contains(" 135)"), + "absolute pad angle 45+90=135 must be emitted" + ); + + let back = crate::parse_kicad_pcb(&text).expect("re-parse"); + let fp = &back.footprints[0]; + assert_eq!(fp.rotation, 90.0); + // ...and relative again in the IR. + assert!((fp.pads[0].rotation - 0.0).abs() < 1e-6); + assert!((fp.pads[1].rotation - 45.0).abs() < 1e-6); + } + #[test] fn pcb_is_deterministic() { let pcb = sample_pcb(); From ba3b52365b6540781074c5a355ff658441c79ff1 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 12:30:09 -0400 Subject: [PATCH 3/5] docs(router): correct every CM5 DRC/SI claim measured against the inflated baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vcad's KiCad importer stored pad angles as read, but KiCad's pad angle is absolute — it already includes the footprint's orientation — while eleven consumers compose `fp.rotation + pad.rotation`. Rotated fine-pitch packages therefore had overlapping pads, manufacturing phantom shorts and clearance violations (PR #684). Those phantoms landed in the stripped-fixture *baseline*, which is the denominator of every route-attributable claim we publish. A bigger floor makes our delta look smaller, so the error flattered us in every comparison — the direction that most needs correcting. Re-measured from scratch on merged main with #684 in. DRC and SI are deterministic given a fixed board file; every figure was run twice with identical results, and the stochastic route stage is reported with wall-clock. Numbers that moved: - stripped-fixture floor: 980 -> 311 short/clearance, 74 -> 23 Clearance (648 violations blamed on the reverse-engineered source were ours) - human production board: 16,485 -> 14,104 violations - our full board: "route-attributable ZERO" was FALSE (74 - 74 = 0 against the inflated floor); corrected, 173 vs floor 23 = 150 attributable - full-board receipt: 2/4 -> 1/4 (vias_per_si_net 2.766 -> 3.128, crossing the 3.0 bound as routability rose 0.983 -> 0.994) - 40-net "receipt Pass (ALL HOLD)" WITHDRAWN -> 3/4 (intra-pair skew 1.353 vs 1.100); it already failed to reproduce pre-fix at 1.347, so the Pass was a one-branch artifact rather than a result - pad-level shorts in the import: ~906 -> 258; 40-net route adds 27, not ~409 (the conclusion stands: Short is not route-attributable, use Clearance) - route wall-clock 113 min -> 958.9 s (16.0 min); full chain 27.2 min, so the M6 "<30 min chain" target is met - pair-first couples 47 pairs in round 0, up from 43 No receipt bound was adjusted. The human board's anchor re-measures bit-identical (9.756 / 1.074 / 0.857 / 2.265, ALL HOLD) because pad angles feed pad geometry, not trace lengths — so the envelope argument survives intact. Co-Authored-By: Claude Opus 5 --- docs/gpu-router-m6-results.md | 95 ++++++++++++++++++++++++++++++++- docs/gpu-router-m7-pair-si.md | 98 +++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 1 deletion(-) diff --git a/docs/gpu-router-m6-results.md b/docs/gpu-router-m6-results.md index 37ec3921..dc476289 100644 --- a/docs/gpu-router-m6-results.md +++ b/docs/gpu-router-m6-results.md @@ -1,5 +1,11 @@ # GPU Router M6 — first end-to-end results +> **Correction (2026-07-25).** Every DRC figure in this document was computed +> against a baseline a geometry bug had inflated, and the SI comparison table +> understated our own delta as a result. See +> [the correction section](#correction-2026-07-25--the-drc-baseline-was-inflated) +> at the end. Claims move only with runs; a correction is a run. + Companion to [gpu-router-m0.md](gpu-router-m0.md) (the charter). This is the first measured run of the complete chain on the CM5 benchmark (schlae/cm5-reveng: 10 copper layers, 436 nets, 3,037 pads, human-routed @@ -99,9 +105,96 @@ toward 30 min (~40–60 min of wall-clock) and possibly a quality gain. (Competitor rows are qualitative — none publish CM5-class results; that is the point of the row.) +## Correction (2026-07-25) — the DRC baseline was inflated + +Every DRC number published above and in +[gpu-router-m7-pair-si.md](gpu-router-m7-pair-si.md) was measured against a +stripped-fixture baseline that one of our own bugs had inflated. + +**The bug.** KiCad stores a pad's angle as an **absolute** value — it already +includes the footprint's orientation. vcad's importer stored that raw value +while eleven consumers compose `fp.rotation + pad.rotation`, double-counting the +footprint rotation on every rotated part. On fine-pitch rotated packages the +neighbouring pads **overlapped**, manufacturing phantom shorts and clearance +violations out of thin air. Fixed in PR #684. + +**Why it mattered more than a normal measurement error.** The phantom violations +landed in the *baseline*, and the baseline is the denominator of every +route-attributable claim. A bigger floor makes our delta look smaller, so the +error flattered us in every published comparison — the direction that most needs +correcting. + +Re-measured from scratch on merged main with #684 in. DRC and SI are +deterministic given a fixed board file, and every figure below was run twice +with **identical** results; the route stage is the only stochastic step and is +reported with its spread. + +### The baseline itself + +| stripped fixture, all copper removed | before | after | +|---|---|---| +| short/clearance | 980 | **311** | +| `Clearance` | 74 | **23** | +| `Short` | ~906 | **258** | + +**648 violations this project attributed to the reverse-engineered source were +ours.** + +### The human production board + +| | before | after | +|---|---|---| +| total violations | 16,485 | **14,104** | +| short/clearance | — | **6,994** | + +The repeated "the production board scores 16,485 violations under these rules" +line was itself inflated by 2,381. The board is still far outside our rule set — +that part of the argument stands — but the number was wrong. + +### Our board, re-routed clean + +Full-board run on the corrected importer: **routability 0.994** in **958.9 s**, +3,791 segments / 895 vias, 0.31× human via count. + +| | published | corrected | +|---|---|---| +| `Clearance` | 74 (claimed = the floor) | **173** | +| floor | 74 | **23** | +| **route-attributable `Clearance`** | **0** | **150** | + +**"Route-attributable violations: ZERO" was false.** It read `74 − 74 = 0` +because the inflated floor happened to equal what our board scored. The honest +figure is 150 against a floor of 23. + +### What did *not* move + +The receipt bounds are untouched, and they remain a valid envelope: the human +board's anchor re-measures **bit-identical** after the fix — +`worst_group_skew 9.756`, `worst_intra_pair_skew 1.074`, +`min_pair_coupled_fraction 0.857`, `vias_per_si_net 2.265`, **ALL HOLD**. Pad +angles feed pad geometry, not trace lengths, so the calibration anchor and the +argument it supports survive this correction intact. + +### The <30 min chain target is met + +| stage | before | now | +|---|---|---| +| route | 113 min (pre-GPU-fix) | **958.9 s ≈ 16.0 min** | +| full chain (route + si_finish) | 2 h 51 m → 67 min | **1,634 s ≈ 27.2 min** | + +The M6 scoreboard row `< 30 min chain | 2 h 51 m` is **closed**. Note this run +shared the machine with another full-board route (load average 23.4), so the +wall-clock is if anything pessimistic — contention only inflates it, and the +target is met regardless. + +The pad fix also helped routing directly: pair-first now lands **47** pairs +coupled in round 0, up from the published 43. Overlapping pads had been sealing +the BGA fields that pairs escape through. + ## Next -1. Wire M4 negotiation into the route/ratchet stages (the <30 min path). +1. ~~Wire M4 negotiation into the route/ratchet stages (the <30 min path).~~ — + done; the chain is 27.2 min, see the correction section above. 2. ~~Reroute-then-descend for extreme-skew pairs; descent for the rest.~~ — done, see [gpu-router-m7-pair-si.md](gpu-router-m7-pair-si.md). Coupled construction went 17 → 39 of 49 pairs (the phantom centerline had been diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index b6bd9a62..14944fe7 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -1,5 +1,12 @@ # M7 — universal pair coupling and the SI receipt +> **Correction (2026-07-25).** The results below were measured against a DRC +> baseline our own pad-rotation bug had inflated (PR #684), and the "40-net +> subset — receipt Pass" headline does not reproduce. Corrected figures are in +> [the correction section](#correction-2026-07-25--re-measured-after-the-pad-rotation-fix) +> at the end; the numbers in the body are left as originally published so the +> record shows what moved. Claims move only with runs; a correction is a run. + Follow-on to [gpu-router-m6-results.md](gpu-router-m6-results.md), whose scoreboard closed with `receipt Pass | 1/4 HOLDS` and named the levers: coupled routing during construction, and reroute-then-descend for the @@ -210,8 +217,99 @@ fixture. The import carries ~906 pad-level shorts, and every routed trace merges copper clusters, multiplying reported net-pair shorts — routing only 40 nets adds ~409. Use the `Clearance` rule for attribution. +## Correction (2026-07-25) — re-measured after the pad-rotation fix + +vcad's KiCad importer stored pad angles as read, but KiCad's pad angle is +absolute (it includes the footprint's orientation) while eleven consumers +compose `fp.rotation + pad.rotation`. Rotated fine-pitch packages therefore had +**overlapping pads**, inflating the DRC baseline every attribution claim in this +document is measured against. Fixed in PR #684; see +[gpu-router-m6-results.md](gpu-router-m6-results.md#correction-2026-07-25--the-drc-baseline-was-inflated) +for the full baseline correction. + +Everything below is re-measured on merged main with #684 in. DRC and SI are +deterministic on a fixed board; every figure was run twice with identical +results. + +### The 40-net subset was not a Pass + +The headline "40-net subset — receipt Pass (ALL HOLD)" is **withdrawn**. It also +failed to reproduce on merged main *before* this fix (measured 2026-07-25: +`worst_intra_pair_skew` 1.347mm against the 1.1mm bound, i.e. 3/4), so the +original Pass was a property of that one branch state, not a reproducible +result. + +| 40-net subset | published | corrected (2 runs, identical) | +|---|---|---| +| worst_group_skew | 0.000 HOLDS | 0.000 HOLDS | +| **worst_intra_pair_skew** | **0.983 HOLDS** | **1.353 BROKEN** (bound 1.100) | +| min_pair_coupled_fraction | 0.777 HOLDS | 0.582 HOLDS | +| vias_per_si_net | 2.727 HOLDS | 2.474 HOLDS | +| **verdict** | **ALL HOLD** | **3 of 4** | + +Routability is unchanged at 1.000 (4.7–5.1 s over two runs). + +**The claim that survives**: the finishing pass still introduces no clearance +violations. Published as "the board's total `Clearance` count is identical with +the finishing pass on and off (74)". The 74 was the inflated floor; corrected, +the 40-net board scores `Clearance` **23** — *exactly* the stripped-fixture +floor. Same finding, honest number. + +### The full board is 1 of 4, not 2 of 4 + +Full-board run on the corrected importer: routability **0.994** in **958.9 s**, +3,791 segments / 895 vias. + +| full board | published (routed + si_finish) | corrected | +|---|---|---| +| worst_group_skew | 9.297 HOLDS | **8.397** HOLDS | +| worst_intra_pair_skew | 37.853 BROKEN | **38.521** BROKEN | +| min_pair_coupled_fraction | 0.000 BROKEN | **0.101** BROKEN | +| **vias_per_si_net** | **2.766 HOLDS** | **3.128 BROKEN** (bound 3.000) | +| **verdict** | **2 of 4** | **1 of 4** | + +`vias_per_si_net` crossed the bound: 294 vias over 94 routed SI nets. The board +now routes more connections (0.994 vs 0.983) and pays for them in vias, so this +claim broke as coverage improved — worth stating plainly rather than reporting +only the routability gain. + +`min_pair_coupled_fraction` moved off exactly zero (0.101) because pair-first +now couples **47** pairs in round 0, up from 43 — overlapping pads had been +sealing the BGA fields pairs escape through. Still far under the 0.5 bound, and +the short `/HS.*` pairs diagnosed above remain the pin. + +### DRC attribution, corrected + +| | published | corrected | +|---|---|---| +| stripped-fixture floor, `Clearance` | 74 | **23** | +| our full board, `Clearance` | — | **173** | +| **route-attributable `Clearance`** | claimed **0** | **150** | + +The note "the import carries ~906 pad-level shorts, and routing only 40 nets +adds ~409" is corrected: the import carries **258** pad-level shorts, and the +40-net route adds **27** (285 total). The *conclusion* is unchanged and still +correct — `Short` counts are not route-attributable on this fixture, because +every routed trace transitively merges same-net copper clusters. Use `Clearance` +for attribution. + +### The bounds are untouched, and still valid + +The human board's anchor re-measures **bit-identical** after the fix — +9.756 / 1.074 / 0.857 / 2.265, ALL HOLD. Pad angles feed pad geometry, not +trace lengths, so the calibration anchor is unaffected and the envelope +argument stands. No bound was adjusted. + +### The open A/B is now closed on the honest baseline + +The "no new route-attributable DRC — router: **not A/B'd**" gap above is +answered: against the corrected floor of 23, the router's own delta is +**+150 `Clearance`**. That is the missing evidence, and it is not zero. + ## Next +0. Reduce the 150 route-attributable clearance violations — now measurable + against an honest floor, and the router's real debt. 1. A short-pair path that keeps both legs on one layer without the lead inset — this alone unpins `min_pair_coupled_fraction` from zero. 2. Higher-effort polish for the detour pairs, or catching them during From 4eea19eeed753a8a19f027967b7dc63b7f3615d1 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 12:45:30 -0400 Subject: [PATCH 4/5] docs(router): add route-stage spread from a second run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second full-board route: routability 0.994 again, byte-identical board (3,791 segments / 895 vias) — the route is deterministic given the tree. Wall-clock 895.5 s vs run 1's 958.9 s. The chain figure stays at one complete run: run 2's si_finish stage was truncated when the machine's disk filled, so its 1,288 s is not a valid chain sample and is not averaged in. Said so in the doc rather than presenting it as a second measurement — a prior version of this document published a single lucky run as typical, which is the habit this correction exists to break. Both runs shared the machine with another session's full-board route, so the timings are pessimistic; the <30 min conclusion holds regardless. Co-Authored-By: Claude Opus 5 --- docs/gpu-router-m6-results.md | 30 +++++++++++++++++++++--------- docs/gpu-router-m7-pair-si.md | 5 +++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/gpu-router-m6-results.md b/docs/gpu-router-m6-results.md index dc476289..83ce3efc 100644 --- a/docs/gpu-router-m6-results.md +++ b/docs/gpu-router-m6-results.md @@ -153,8 +153,10 @@ that part of the argument stands — but the number was wrong. ### Our board, re-routed clean -Full-board run on the corrected importer: **routability 0.994** in **958.9 s**, -3,791 segments / 895 vias, 0.31× human via count. +Full-board route on the corrected importer, **two runs**: **routability 0.994** +both times, producing a byte-identical board (3,791 segments / 895 vias, +5,301 mm copper, 380 routed / 28 unrouted), 0.31× human via count. The route is +deterministic given the tree; only wall-clock varies (958.9 s and 895.5 s). | | published | corrected | |---|---|---| @@ -179,13 +181,23 @@ argument it supports survive this correction intact. | stage | before | now | |---|---|---| -| route | 113 min (pre-GPU-fix) | **958.9 s ≈ 16.0 min** | -| full chain (route + si_finish) | 2 h 51 m → 67 min | **1,634 s ≈ 27.2 min** | - -The M6 scoreboard row `< 30 min chain | 2 h 51 m` is **closed**. Note this run -shared the machine with another full-board route (load average 23.4), so the -wall-clock is if anything pessimistic — contention only inflates it, and the -target is met regardless. +| route | 113 min (pre-GPU-fix) | **895.5 / 958.9 s ≈ 14.9–16.0 min** (2 runs) | +| full chain (route + si_finish) | 2 h 51 m → 67 min | **1,634 s ≈ 27.2 min** (1 run) | + +The M6 scoreboard row `< 30 min chain | 2 h 51 m` is **closed**. + +Two honesty notes on these timings, since a prior version of this document +published a single lucky run as typical: + +- Both runs shared the machine with another session's full-board route (load + average 23.4). Contention only inflates wall-clock, so the `<30 min` + conclusion holds a fortiori — but neither number is a clean solo measurement. +- The chain figure rests on **one** complete run, not two. The second attempt's + route stage finished normally (895.5 s, identical board) but its `si_finish` + stage was truncated when the machine's disk filled, so its 1,288 s is not a + valid chain sample and is not averaged in. The route-stage spread above is + from two complete runs; the chain number needs a second clean run to earn the + same standing. The pad fix also helped routing directly: pair-first now lands **47** pairs coupled in round 0, up from the published 43. Overlapping pads had been sealing diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index 14944fe7..8002c3c2 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -257,8 +257,9 @@ floor. Same finding, honest number. ### The full board is 1 of 4, not 2 of 4 -Full-board run on the corrected importer: routability **0.994** in **958.9 s**, -3,791 segments / 895 vias. +Full-board route on the corrected importer, two runs: routability **0.994** +both times, byte-identical board (3,791 segments / 895 vias) — the route is +deterministic given the tree. Wall-clock 895.5 / 958.9 s. | full board | published (routed + si_finish) | corrected | |---|---|---| From 0b8efc4a6a49a14a55df8efe794cca82bdf18706 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sat, 25 Jul 2026 12:57:41 -0400 Subject: [PATCH 5/5] docs(router): re-verify the corrected CM5 figures against current main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main moved during this work: #685 (Eagle pad rotation), #687 (Gerber apertures turn with the pad), and #691 (per-layer controlled-impedance geometry). #691 rewrites router/pair.rs and adds a fifth SI claim, so the corrected numbers were re-run against it rather than assumed still valid. Nothing moved. The stripped floor (311 / 23), our board's DRC (Clearance 173), and the 40-net route (byte-identical board, receipt 1.353 / 0.582 / 2.474) all reproduce exactly — #691's per-layer widths are opt-in on target_impedance, which the CM5 fixture's classes do not declare. The new pair_impedance_correct_fraction claim is vacuous on this fixture: it reports 0.000 against a bound of 0.000 with the reason "no differential net class declares a target impedance — nothing to verify". The receipt is now five claims wide, so the raw fraction reads 2 of 5 on the full board and 4 of 5 on the subset. Those are not improvements, and the docs say so — the honest counts against claims that actually verify something remain 1 of 4 and 3 of 4. Also resolves the merge conflict with main in router/pair.rs by taking main's version; this branch carries no crate changes, only docs. Co-Authored-By: Claude Opus 5 --- docs/gpu-router-m7-pair-si.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/gpu-router-m7-pair-si.md b/docs/gpu-router-m7-pair-si.md index 8002c3c2..b547a30b 100644 --- a/docs/gpu-router-m7-pair-si.md +++ b/docs/gpu-router-m7-pair-si.md @@ -245,7 +245,8 @@ result. | **worst_intra_pair_skew** | **0.983 HOLDS** | **1.353 BROKEN** (bound 1.100) | | min_pair_coupled_fraction | 0.777 HOLDS | 0.582 HOLDS | | vias_per_si_net | 2.727 HOLDS | 2.474 HOLDS | -| **verdict** | **ALL HOLD** | **3 of 4** | +| pair_impedance_correct_fraction | — | 0.000 HOLDS *(vacuous, see below)* | +| **verdict** | **ALL HOLD** | **3 of 4** (4 of 5 counting the vacuous claim) | Routability is unchanged at 1.000 (4.7–5.1 s over two runs). @@ -267,7 +268,8 @@ deterministic given the tree. Wall-clock 895.5 / 958.9 s. | worst_intra_pair_skew | 37.853 BROKEN | **38.521** BROKEN | | min_pair_coupled_fraction | 0.000 BROKEN | **0.101** BROKEN | | **vias_per_si_net** | **2.766 HOLDS** | **3.128 BROKEN** (bound 3.000) | -| **verdict** | **2 of 4** | **1 of 4** | +| pair_impedance_correct_fraction | — | 0.000 HOLDS *(vacuous, see below)* | +| **verdict** | **2 of 4** | **1 of 4** (2 of 5 counting the vacuous claim) | `vias_per_si_net` crossed the bound: 294 vias over 94 routed SI nets. The board now routes more connections (0.994 vs 0.983) and pays for them in vias, so this @@ -294,6 +296,28 @@ correct — `Short` counts are not route-attributable on this fixture, because every routed trace transitively merges same-net copper clusters. Use `Clearance` for attribution. +### Re-verified against current main (#685, #687, #691) + +Main moved while this correction was being measured: #685 (Eagle `.brd` pad +rotation), #687 (Gerber apertures turn with the pad), and #691 (per-layer +controlled-impedance geometry). #691 rewrites `router/pair.rs` and adds a fifth +SI claim, so the corrected figures were re-run against it rather than assumed +still valid. + +**Nothing moved.** The stripped floor (311 / 23), our board's DRC (`Clearance` +173), and the 40-net route (byte-identical board — 762 segments, 92 vias, +receipt 1.353 / 0.582 / 2.474) all reproduce exactly. #691's per-layer widths +are opt-in on `target_impedance`, which the CM5 fixture's classes do not +declare, so the router's behaviour on this board is unchanged. + +The new `pair_impedance_correct_fraction` claim is therefore **vacuous here** — +it reports `0.000` against a bound of `0.000` with the reason "no differential +net class declares a target impedance — nothing to verify", and HOLDS trivially. +The receipt is now 5 claims wide, so the raw fraction reads 2 of 5 on the full +board and 4 of 5 on the subset. **Those are not improvements.** The honest +counts against claims that actually verify something are unchanged: **1 of 4** +and **3 of 4**. + ### The bounds are untouched, and still valid The human board's anchor re-measures **bit-identical** after the fix —