diff --git a/changelog/entries/2026-07-25-router-window-repair-commit-parity.json b/changelog/entries/2026-07-25-router-window-repair-commit-parity.json new file mode 100644 index 00000000..63212beb --- /dev/null +++ b/changelog/entries/2026-07-25-router-window-repair-commit-parity.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-25-router-window-repair-commit-parity", + "version": "0.9.4", + "date": "2026-07-25", + "category": "fix", + "title": "Autoroute keeps window-repaired connections it used to drop", + "summary": "The last-resort window repair now searches at the widest net-class width, merges stacked via barrels, and probes real drills, so routings it finds survive the commit.", + "features": ["pcb", "routing", "drc"], + "mcpTools": ["route_nets", "run_drc"] +} diff --git a/crates/vcad-ecad-pcb/src/router/auto.rs b/crates/vcad-ecad-pcb/src/router/auto.rs index fdf5ab00..d268c00c 100644 --- a/crates/vcad-ecad-pcb/src/router/auto.rs +++ b/crates/vcad-ecad-pcb/src/router/auto.rs @@ -21,7 +21,9 @@ use crate::ratsnest::{compute_ratsnest, NetConnection, Netlist, NetlistNet, Rats use crate::session::{RouteSession, SpanId}; use crate::spatial::{point_in_polygon, CopperElement, CopperGeom}; -use super::complete::{route_window_complete, CompleteOutcome}; +use super::complete::{ + path_vias, route_window_complete_pinned, CompleteOutcome, ViaClass, WindowBudget, +}; use super::congestion::Congestion; use super::escape; use super::global::plan_with_scarcity; @@ -3322,32 +3324,50 @@ fn joint_window_repair( // DFS budget-caps honestly (BudgetExhausted, never a fake // proof) if 10 layers is too much for exhaustion. let cl: Vec = copper; - match route_window_complete( + // Search at the WIDEST width any of these nets will commit + // at, not the board default. `validate_and_commit` below + // commits each net at `width_for(net, width)`; a corridor + // found at the default is rejected there the moment an + // SI-class net needs more copper than the search assumed — + // the connection is lost to a mismatch, not to the board. + let search_width = lost + .iter() + .map(|c| session.width_for(&c.0, width)) + .fold(width, f64::max); + // Hand the router the via geometry this loop actually + // commits, so its legality model is the commit rule rather + // than a heuristic: the pad is probed at its real size and + // the drill is checked against hole-to-hole, which no + // layer-scoped copper probe can see. + let via_class = ViaClass { + pad_diameter: pcb.rules.default_rules.via_diameter, + drill: pcb.rules.default_rules.via_drill, + }; + match route_window_complete_pinned( session, (Vec2::new(gw.0[0], gw.0[1]), Vec2::new(gw.1[0], gw.1[1])), &cl, &lost, - width, - 2_000_000, + // Terminals unconstrained: unlike the CM5 verdict driver + // this loop has no pad-layer map for `lost`, so stubs + // may still surface off the pad's own layer here. + &[], + search_width, + Some(via_class), + WindowBudget::new(2_000_000), ) { CompleteOutcome::Routed(paths) => { let mut still_lost = Vec::new(); for (conn, segs) in lost.drain(..).zip(paths) { - // Vias sit at shared endpoints of consecutive - // segments on different layers (adjacent span). - let mut vias = Vec::new(); - for w2 in segs.windows(2) { - if w2[0].2 != w2[1].2 { - vias.push((w2[0].1, w2[0].2, w2[1].2)); - } - } + let vias = path_vias(&segs); + let commit_width = session.width_for(&conn.0, width); let cand = Candidate { thin_segments: vec![], - thin_width: session.width_for(&conn.0, width), + thin_width: commit_width, net: conn.0.clone(), from: conn.1, to: conn.2, - width: session.width_for(&conn.0, width), + width: commit_width, segments: segs, vias, }; diff --git a/crates/vcad-ecad-pcb/src/router/complete.rs b/crates/vcad-ecad-pcb/src/router/complete.rs index fb3a3adb..83a9c693 100644 --- a/crates/vcad-ecad-pcb/src/router/complete.rs +++ b/crates/vcad-ecad-pcb/src/router/complete.rs @@ -178,6 +178,33 @@ pub enum CompleteOutcome { BudgetExhausted, } +/// The real vias of a [`CompleteOutcome::Routed`] path: one barrel per run of +/// layer changes at a shared point, as `(center, top, bottom)`. +/// +/// A path that steps F.Cu → In1 → In2 at one point is ONE barrel spanning +/// F.Cu → In2, not two. Reading the transitions off `windows(2)` without +/// merging them emits coincident vias, which stacks drills at zero spacing and +/// fails hole-to-hole against itself — a "routed" path that cannot commit. The +/// coincidence test also guards the other direction: consecutive segments on +/// different layers whose endpoints do *not* meet are not a via at all. +pub fn path_vias(path: &[(Vec2, Vec2, PcbLayer)]) -> Vec<(Vec2, PcbLayer, PcbLayer)> { + let mut vias: Vec<(Vec2, PcbLayer, PcbLayer)> = Vec::new(); + for w in path.windows(2) { + let (_, b0, l0) = w[0]; + let (a1, _, l1) = w[1]; + if l0 == l1 || dist(b0, a1) > 1e-9 { + continue; + } + match vias.last_mut() { + // Same point, and the previous barrel ends where this one starts: + // extend it rather than opening a second one. + Some((p, _, end)) if dist(*p, b0) < 1e-9 && *end == l0 => *end = l1, + _ => vias.push((b0, l0, l1)), + } + } + vias +} + /// Decide joint routability of `conns` inside `window`. /// /// * `window` — `(lo, hi)` corners of the search rectangle (board mm). Must @@ -1871,4 +1898,50 @@ mod tests { assert_connected(&conns, &routed); assert_probe_legal(&session, &conns, &routed, 0.25); } + + #[test] + fn a_run_of_layer_changes_at_one_point_is_one_barrel() { + // F.Cu -> In1 -> In2 without moving: one barrel spanning F.Cu -> In2. + // Reading the transitions off `windows(2)` unmerged yields two + // coincident vias, which stacks drills at zero spacing and fails + // hole-to-hole against itself — a "routed" path that cannot commit. + let p = Vec2::new(5.0, 5.0); + let path = vec![ + (Vec2::new(0.0, 5.0), p, PcbLayer::FCu), + (p, p, PcbLayer::In1Cu), + (p, Vec2::new(10.0, 5.0), PcbLayer::In2Cu), + ]; + assert_eq!( + path_vias(&path), + vec![(p, PcbLayer::FCu, PcbLayer::In2Cu)], + "a run of transitions at one point must merge into a single barrel" + ); + } + + #[test] + fn separate_layer_changes_stay_separate_barrels() { + // Two transitions at *different* points are two real vias, and a layer + // change across a gap (endpoints not coincident) is no via at all. + let (p, q) = (Vec2::new(5.0, 5.0), Vec2::new(9.0, 5.0)); + let path = vec![ + (Vec2::new(0.0, 5.0), p, PcbLayer::FCu), + (p, q, PcbLayer::In1Cu), + (q, Vec2::new(14.0, 5.0), PcbLayer::FCu), + ]; + assert_eq!( + path_vias(&path), + vec![ + (p, PcbLayer::FCu, PcbLayer::In1Cu), + (q, PcbLayer::In1Cu, PcbLayer::FCu), + ] + ); + let disjoint = vec![ + (Vec2::new(0.0, 5.0), p, PcbLayer::FCu), + (Vec2::new(20.0, 5.0), Vec2::new(30.0, 5.0), PcbLayer::In1Cu), + ]; + assert!( + path_vias(&disjoint).is_empty(), + "a layer change whose endpoints do not meet is not a via" + ); + } }