Skip to content
Closed
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-via-hole-to-hole.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "2026-07-24-router-via-hole-to-hole",
"version": "0.9.4",
"date": "2026-07-24",
"category": "fix",
"title": "Autorouter enforces via hole-to-hole spacing",
"summary": "route_nets' legality oracle now indexes drilled holes independently of copper, so two vias whose layer spans don't overlap can no longer be placed with colliding drills — the hole-to-hole rule is enforced at probe time, not found by a later DRC.",
"features": ["pcb", "autorouter", "drc"],
"mcpTools": ["route_nets", "run_drc", "validate_for_fab"]
}
11 changes: 6 additions & 5 deletions crates/vcad-ecad-pcb/examples/cm5_verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,11 @@ impl Driver {
.probe(&CopperGeom::Disc { center: p, r: via_r }, layer, net, clr)
.legal
})
// Hole-to-hole is layer- and net-agnostic, so the copper probe
// above cannot see it: two vias on disjoint layer spans share no
// layer yet still collide in the drill file.
&& self.session.probe_drill(p, via_drill).legal
// Hole-to-hole is layer-agnostic, so the copper probe above cannot
// see it: two vias on disjoint layer spans share no layer yet still
// collide in the drill file. The probe compares net-agnostically
// (only a coincident same-net barrel is exempt), matching the DRC.
&& self.session.probe_hole(p, via_drill, net).legal
// ...and two vias of THIS path must clear each other, which the
// session cannot judge until they are committed.
&& vias.iter().all(|&(q, _, _)| {
Expand Down Expand Up @@ -594,7 +595,7 @@ impl Driver {
},
});
}
self.session.commit_drill(p, via_drill);
self.session.commit_hole(p, via_drill, net);
self.pcb.vias.push(vcad_ir::ecad::Via {
position: p,
diameter: via_d,
Expand Down
140 changes: 136 additions & 4 deletions crates/vcad-ecad-pcb/src/router/auto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2143,6 +2143,20 @@ pub(super) fn validate_and_commit(
if reused {
continue;
}
// Drilled barrels collide regardless of layer span — the copper probes
// below only compare vias that share a layer, so a blind/buried pair on
// disjoint spans would otherwise sail through.
let hp = session.probe_via_hole(p, net);
if !hp.legal {
log::debug!(
"validate: {net} via ({:.3},{:.3}) hole-to-hole illegal — {} at {:.3}mm",
p.x,
p.y,
hp.blocker.as_ref().map(|b| b.1.as_str()).unwrap_or("?"),
hp.min_gap
);
return None;
}
let disc = CopperGeom::Disc {
center: p,
r: via_r,
Expand Down Expand Up @@ -2665,9 +2679,12 @@ fn escape_endpoint(
center: p,
r: via_r,
};
copper
.iter()
.all(|&l| session.probe(&disc, l, net, clearance).legal)
// The drilled barrel first: cheap, and layer-independent (copper
// probes can't see a hole conflict across disjoint layer spans).
session.probe_via_hole(p, net).legal
&& copper
.iter()
.all(|&l| session.probe(&disc, l, net, clearance).legal)
};
// A coincident same-net via already on the board — reuse it rather than
// stacking a second drill at the same spot.
Expand Down Expand Up @@ -2788,7 +2805,9 @@ fn commit_stub(
commit_seg(session, net, a, b, hw, layer)
}

/// Commit a via as a disc on every copper layer it spans (a through via).
/// Commit a via as a disc on every copper layer it spans (a through via),
/// plus its drilled barrel — which is layer-independent, so a later via on a
/// disjoint layer span still sees it in [`RouteSession::probe_hole`].
pub(super) fn commit_via(
session: &mut RouteSession,
net: &str,
Expand All @@ -2797,6 +2816,8 @@ pub(super) fn commit_via(
copper: &[PcbLayer],
spans: &mut Vec<SpanId>,
) {
let drill = session.via_drill_for(net);
spans.push(session.commit_hole(p, drill, net));
for &l in copper {
spans.push(session.commit(CopperElement {
min: [p.x - via_r, p.y - via_r],
Expand Down Expand Up @@ -3541,6 +3562,117 @@ mod tests {
}
}

/// The same board with a four-layer stackup, so two vias can occupy
/// *disjoint* copper layer spans.
fn board_4layer() -> Pcb {
let mut pcb = board(vec![]);
pcb.stackup.layers = [
PcbLayer::FCu,
PcbLayer::In1Cu,
PcbLayer::In2Cu,
PcbLayer::BCu,
]
.into_iter()
.map(|layer| StackupLayer {
layer,
copper_thickness: Some(0.035),
dielectric_thickness: Some(0.5),
dielectric_er: Some(4.5),
material: Some("FR4".into()),
})
.collect();
pcb
}

#[test]
fn via_refused_when_drills_collide_across_disjoint_layer_spans() {
// The hole class the CM5 fab campaign found: two vias whose layer
// spans do not overlap share no copper layer, so every clearance probe
// passes — while their DRILLS physically collide.
let pcb = board_4layer();
let mut session = RouteSession::from_pcb(&pcb);
let via_r = pcb.rules.default_rules.via_diameter / 2.0; // 0.4mm
let drill = pcb.rules.default_rules.via_drill; // 0.4mm

// Net A takes a blind via on the top half of the stack.
let a = Vec2::new(25.0, 15.0);
let mut spans = Vec::new();
commit_via(
&mut session,
"A",
a,
via_r,
&[PcbLayer::FCu, PcbLayer::In1Cu],
&mut spans,
);

// Net B wants a buried via 0.5mm away on the bottom half: drills leave
// a 0.5 - 0.2 - 0.2 = 0.1mm gap against the board's 0.5mm rule.
let b = Vec2::new(25.5, 15.0);
let disc = CopperGeom::Disc {
center: b,
r: via_r,
};
assert!(
[PcbLayer::In2Cu, PcbLayer::BCu]
.iter()
.all(|&l| session.probe(&disc, l, "B", 0.2).legal),
"copper probes must see no conflict — that is the hole in the oracle"
);
let hp = session.probe_via_hole(b, "B");
assert!(!hp.legal, "0.1mm hole gap must violate the 0.5mm rule");
assert!((hp.min_gap - 0.1).abs() < 1e-9, "gap was {}", hp.min_gap);
assert_eq!(hp.blocker.map(|(_, n)| n).as_deref(), Some("A"));

// The commit gate must refuse the placement, mutating nothing.
let cand = |p: Vec2| Candidate {
net: "B".into(),
from: p,
to: p,
width: 0.25,
segments: vec![],
vias: vec![(p, PcbLayer::In2Cu, PcbLayer::BCu)],
thin_segments: vec![],
thin_width: 0.25,
};
let before = session.len();
assert!(
validate_and_commit(&mut session, &pcb, cand(b), &[]).is_none(),
"a via whose drill collides across disjoint layer spans must be refused"
);
assert_eq!(session.len(), before, "a refused via commits nothing");

// Clear of the rule (1.0mm apart → 0.6mm gap): accepted, proving the
// refusal above is the hole rule and not a blanket ban.
let far = Vec2::new(26.0, 15.0);
assert!(session.probe_via_hole(far, "B").legal);
assert!(validate_and_commit(&mut session, &pcb, cand(far), &[]).is_some());

// And the whole thing agrees with the DRC: drop both vias on the board
// at the illegal spacing and the real checker flags HoleToHole.
let mut judged = pcb.clone();
for (p, net, s, e) in [
(a, "A", PcbLayer::FCu, PcbLayer::In1Cu),
(b, "B", PcbLayer::In2Cu, PcbLayer::BCu),
] {
judged.vias.push(Via {
position: p,
diameter: via_r * 2.0,
drill,
start_layer: s,
end_layer: e,
net: net.into(),
source: None,
});
}
assert!(
check_drc(&judged)
.iter()
.any(|v| v.rule == crate::DrcRuleType::HoleToHole),
"the DRC must flag exactly what the session now refuses"
);
}

/// Apply the router's output to the board (as the MCP tool does).
fn apply(pcb: &mut Pcb, r: &RouteAllResult) {
for t in &r.traces {
Expand Down
19 changes: 16 additions & 3 deletions crates/vcad-ecad-pcb/src/router/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,19 @@ pub fn route_window_complete_pinned(
.max(0.11)
.max(via.map_or(0.0, |v| v.pad_diameter / 2.0));
let via_drill = via.map(|v| v.drill);
// Every layer change the router emits must also clear existing *holes*.
let barrel_ok =
|center: Vec2| -> bool { via_drill.is_none_or(|d| session.probe_drill(center, d).legal) };
// Every layer change the router emits must also clear existing *holes* —
// the layer-independent check no per-layer copper probe below can make. The
// barrel is decided jointly for the window's `conns`, so it must keep the
// hole rule for each of them (same-net exemptions differ by net, so the
// strictest — legal for every conn — is the sound choice). When the caller
// pins a `ViaClass` its drill is the exact size written back to the board;
// otherwise the barrel is sized from each net's class, as the router does.
let barrel_ok = |center: Vec2| -> bool {
conns.iter().all(|(net, _, _)| match via_drill {
Some(d) => session.probe_hole(center, d, net).legal,
None => session.probe_via_hole(center, net).legal,
})
};

// --- Free-node raster (fixed copper only) ----------------------------
// A node is free iff a zero-length capsule (trace centre) at the cell
Expand Down Expand Up @@ -549,6 +559,9 @@ pub fn route_window_complete_pinned(
center: grid.world(ca),
r: via_r,
};
// Drill barrels ignore layer spans, so `barrel_ok` checks the
// hole-to-hole rule against every other hole on the board — the
// check no per-layer copper probe below can make.
barrel_ok(grid.world(ca))
&& conns.iter().zip(&clearances).all(|((net, _, _), &clr)| {
session.probe(&disc, layers[la], net, clr).legal
Expand Down
15 changes: 10 additions & 5 deletions crates/vcad-ecad-pcb/src/router/maze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
//! layer-aware [`route_net_maze3d`], whose A* runs over `(x, y, layer)` with
//! via transitions as costed edges — the search *chooses* where to change
//! layers instead of being handed a layer by the caller. Via legality is
//! probed on every copper layer the (through) via spans, so a 3D route is as
//! DRC-clean as a 2D one.
//! probed on every copper layer the via spans, plus the drill barrel against
//! every other-net hole regardless of span, so a 3D route is as DRC-clean as
//! a 2D one.

use std::cmp::Ordering;
use std::collections::BinaryHeap;
Expand Down Expand Up @@ -276,9 +277,13 @@ pub fn route_net_maze3d(
center: p,
r: via_r,
};
layers[a.min(b)..=a.max(b)]
.iter()
.all(|&l| session.probe(&disc, l, net, clearance).legal)
// The drill barrel is layer-independent: a blind/buried via must keep
// the hole-to-hole rule against every other-net hole on the board, not
// just the ones sharing its copper layers.
session.probe_via_hole(p, net).legal
&& layers[a.min(b)..=a.max(b)]
.iter()
.all(|&l| session.probe(&disc, l, net, clearance).legal)
};
let cong = congestion.filter(|c| !c.is_flat());
let node_cost =
Expand Down
29 changes: 28 additions & 1 deletion crates/vcad-ecad-pcb/src/router/pair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,15 @@ mod tests {
}],
net_class_assignments: Default::default(),
edge_clearance: 0.5,
hole_to_hole: 0.5,
// A pair transitions layers by dropping the two legs' vias
// side by side, so the rule has to admit the pitch the pair
// itself declares: legs sit gap + width = 0.45mm apart, and
// two 0.2mm drills there leave a 0.25mm hole gap. A 0.5mm
// rule would make *every* transition on this board
// unmanufacturable — the router now refuses those at probe
// time instead of emitting them (see
// `RouteSession::probe_hole`).
hole_to_hole: 0.2,
min_annular_ring: 0.15,
min_drill: 0.2,
},
Expand Down Expand Up @@ -1969,6 +1977,24 @@ mod tests {
worst >= clearance - 1e-9,
"twin edge clearance {worst:.3}mm < {clearance}"
);
assert_twin_holes_clear(&pcb, &mine, &theirs);
}

/// The twins' via DRILLS must keep the board's hole-to-hole rule — the
/// check that has no copper-layer counterpart when the two vias land on
/// disjoint layer spans.
fn assert_twin_holes_clear(pcb: &Pcb, mine: &Placed, theirs: &Placed) {
let r = pcb.rules.default_rules.via_drill / 2.0;
for &(p, _, _) in &mine.via_pts {
for &(q, _, _) in &theirs.via_pts {
let gap = dist(p, q) - 2.0 * r;
assert!(
gap >= pcb.rules.hole_to_hole - 1e-6,
"twin via hole gap {gap:.3}mm < {}",
pcb.rules.hole_to_hole
);
}
}
}

/// Twin-clearance check shared by the transition repros, using the
Expand Down Expand Up @@ -2208,6 +2234,7 @@ mod tests {
);
let (mine, theirs) = r.expect("pair must route the L across the wall");
assert_twin_clear(&mine, &theirs, 0.15);
assert_twin_holes_clear(&pcb, &mine, &theirs);
}

/// Probe-level contract: with a declared pair class, leg-width copper of
Expand Down
Loading