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
Original file line number Diff line number Diff line change
@@ -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"]
}
48 changes: 34 additions & 14 deletions crates/vcad-ecad-pcb/src/router/auto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PcbLayer> = 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,
};
Expand Down
73 changes: 73 additions & 0 deletions crates/vcad-ecad-pcb/src/router/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
);
}
}
Loading