From 511c4f22033ca99d5040ac1a1afb6d1b7bedce7b Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 21:07:52 -0400 Subject: [PATCH 1/2] fix(router): enforce via hole-to-hole spacing at probe time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session's legality oracle only compared copper, and copper is indexed per layer. Two vias whose layer spans do not overlap share no copper element, so `RouteSession::probe` never compared them — while their DRILL barrels, which are layer-independent, could physically collide. The 2026-07-24 CM5 fab campaign found 9 such cross-net pairs on a routed board (worst: -0.024mm overlap, /MIPI0.D3_N vs /USB3-0.DM); they were caught only by a post-hoc scan. Give the session a second index over drilled holes — via barrels and through-hole pad drills — with `probe_hole` / `commit_hole`, mirroring `drc::check_hole_to_hole` exactly: illegal when `dist(centers) - rA - rB < rules.hole_to_hole - 1e-6`, regardless of layer span. Hole ids share the span-id space, so rip-up removes a via's barrel with its copper. Same-net holes stay exempt (coincident same-net drills are the post-route merge case); net ties are not exempt, since a tie is electrical and a drill is mechanical — exactly as the DRC treats them. Wired into every via-placement path: the maze's in-search `via_ok`, the escape/stitch `via_legal`, `validate_and_commit`'s commit gate, and the complete window router's via edges. The hole query is one small R-tree lookup per candidate via; a 40-net CM5 subset routes byte-identically to before at the same wall-clock. The diff-pair repro board's rules were internally inconsistent — a 0.5mm hole-to-hole rule with 0.2mm drills on legs 0.45mm apart makes every pair transition unmanufacturable — so it now declares the 0.2mm rule its own geometry implies, and both transition repros assert the twins' drills keep it. Co-Authored-By: Claude Opus 5 --- .../2026-07-24-router-via-hole-to-hole.json | 10 + crates/vcad-ecad-pcb/src/router/auto.rs | 137 +++++++- crates/vcad-ecad-pcb/src/router/complete.rs | 7 +- crates/vcad-ecad-pcb/src/router/maze.rs | 15 +- crates/vcad-ecad-pcb/src/router/pair.rs | 29 +- crates/vcad-ecad-pcb/src/session.rs | 310 +++++++++++++++++- 6 files changed, 490 insertions(+), 18 deletions(-) create mode 100644 changelog/entries/2026-07-24-router-via-hole-to-hole.json diff --git a/changelog/entries/2026-07-24-router-via-hole-to-hole.json b/changelog/entries/2026-07-24-router-via-hole-to-hole.json new file mode 100644 index 000000000..c46065bb1 --- /dev/null +++ b/changelog/entries/2026-07-24-router-via-hole-to-hole.json @@ -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"] +} diff --git a/crates/vcad-ecad-pcb/src/router/auto.rs b/crates/vcad-ecad-pcb/src/router/auto.rs index 34e100617..868c45875 100644 --- a/crates/vcad-ecad-pcb/src/router/auto.rs +++ b/crates/vcad-ecad-pcb/src/router/auto.rs @@ -1873,6 +1873,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, @@ -2395,9 +2409,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. @@ -2518,7 +2535,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, @@ -2527,6 +2546,8 @@ pub(super) fn commit_via( copper: &[PcbLayer], spans: &mut Vec, ) { + 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], @@ -3263,6 +3284,114 @@ 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 { diff --git a/crates/vcad-ecad-pcb/src/router/complete.rs b/crates/vcad-ecad-pcb/src/router/complete.rs index da58368b9..831f21742 100644 --- a/crates/vcad-ecad-pcb/src/router/complete.rs +++ b/crates/vcad-ecad-pcb/src/router/complete.rs @@ -274,12 +274,15 @@ pub fn route_window_complete( } else { // Via edge: disc on both spanned (adjacent) layers. debug_assert_eq!(ca, cb); + let center = grid.world(ca); let disc = CopperGeom::Disc { - center: grid.world(ca), + center, r: via_r, }; conns.iter().zip(&clearances).all(|((net, _, _), &clr)| { - session.probe(&disc, layers[la], net, clr).legal + // Drill barrels ignore layer spans — check the hole rule too. + session.probe_via_hole(center, net).legal + && session.probe(&disc, layers[la], net, clr).legal && session.probe(&disc, layers[lb], net, clr).legal }) }; diff --git a/crates/vcad-ecad-pcb/src/router/maze.rs b/crates/vcad-ecad-pcb/src/router/maze.rs index 177af65e2..c714c74c8 100644 --- a/crates/vcad-ecad-pcb/src/router/maze.rs +++ b/crates/vcad-ecad-pcb/src/router/maze.rs @@ -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; @@ -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 = diff --git a/crates/vcad-ecad-pcb/src/router/pair.rs b/crates/vcad-ecad-pcb/src/router/pair.rs index 4f95ad36b..6514ff9ac 100644 --- a/crates/vcad-ecad-pcb/src/router/pair.rs +++ b/crates/vcad-ecad-pcb/src/router/pair.rs @@ -1113,7 +1113,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, }, @@ -1366,6 +1374,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 @@ -1468,6 +1494,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 diff --git a/crates/vcad-ecad-pcb/src/session.rs b/crates/vcad-ecad-pcb/src/session.rs index 099cc28cd..ae57d149b 100644 --- a/crates/vcad-ecad-pcb/src/session.rs +++ b/crates/vcad-ecad-pcb/src/session.rs @@ -16,6 +16,13 @@ //! - [`RouteSession::remove`] — rip a span back out (tombstone + lazy compaction) //! so rip-up-and-reroute is O(drop) rather than a full index rebuild. //! +//! Copper is not the whole legality story: a *drilled hole* obeys a rule that +//! has nothing to do with layers. [`RouteSession::probe_hole`] / +//! [`RouteSession::commit_hole`] maintain a second index over via barrels and +//! through-hole pad drills, so two blind/buried vias on disjoint layer spans — +//! which share no copper element and therefore never meet in `probe` — still +//! have to keep `rules.hole_to_hole` apart. +//! //! This is the structural inversion the router rests on: clearance becomes a //! constraint consulted *during* the search, not a violation detected after it. @@ -74,6 +81,46 @@ pub struct Blocker { pub distance: f64, } +/// A drilled hole (a via barrel or a through-hole pad) indexed for the +/// hole-to-hole rule. +/// +/// Holes live in their own index because the rule they obey is *mechanical*, +/// not electrical: a drill bit does not care which copper layers the via +/// spans. Two blind/buried vias on disjoint layer spans share no copper +/// element and so never meet in [`RouteSession::probe`], yet their barrels +/// can still collide. +#[derive(Debug, Clone)] +struct SessionHole { + id: SpanId, + center: Vec2, + /// Drill *radius* (mm). + radius: f64, + net: String, +} + +impl RTreeObject for SessionHole { + type Envelope = AABB<[f64; 2]>; + + fn envelope(&self) -> Self::Envelope { + AABB::from_corners( + [self.center.x - self.radius, self.center.y - self.radius], + [self.center.x + self.radius, self.center.y + self.radius], + ) + } +} + +/// Outcome of probing a candidate drilled hole against the session. +#[derive(Debug, Clone, PartialEq)] +pub struct HoleProbeResult { + /// True when every other-net hole keeps the `hole_to_hole` rule. + pub legal: bool, + /// Smallest edge-to-edge gap to any other-net hole (mm), i.e. center + /// distance minus both drill radii. `f64::INFINITY` when nothing is near. + pub min_gap: f64, + /// The closest offending hole's span and net, when illegal. + pub blocker: Option<(SpanId, String)>, +} + /// Outcome of probing a candidate geometry against the session. #[derive(Debug, Clone, PartialEq)] pub struct ProbeResult { @@ -166,6 +213,24 @@ pub struct RouteSession { /// so a wide net's clearance is never missed when it exceeds the candidate's. max_clearance: f64, net_width: HashMap, + /// Drilled holes (vias + through-hole pads), indexed independently of + /// copper — see [`SessionHole`]. Ids share `live` with copper spans, so + /// [`RouteSession::remove`] rips a via's barrel out with its copper. + holes: RTree, + /// True at ids that name a hole rather than a copper span (parallel to + /// `live`), so `remove` knows which index to compact. + is_hole: Vec, + /// Count of tombstoned-but-not-yet-compacted holes. + holes_dead: usize, + /// `rules.hole_to_hole` — minimum edge-to-edge drill spacing (mm). + hole_to_hole: f64, + /// Largest drill radius any hole has (or any net class can mint), so the + /// hole broadphase always reaches far enough. + max_hole_radius: f64, + /// net → via drill diameter from its net class (mirrors how a routed via + /// is sized when it is written back to the board). + net_via_drill: HashMap, + default_via_drill: f64, /// Per-span bboxes (parallel to `live`), so a remove can stamp the dirty /// grid without consulting the tree. bounds: Vec<[f64; 4]>, @@ -236,11 +301,46 @@ impl RouteSession { edge_ring(cutout); } let elems = elems; - let live = vec![true; elems.len()]; - let bounds: Vec<[f64; 4]> = elems + let mut live = vec![true; elems.len()]; + let mut is_hole = vec![false; elems.len()]; + let mut bounds: Vec<[f64; 4]> = elems .iter() .map(|e| [e.min[0], e.min[1], e.max[0], e.max[1]]) .collect(); + + // Every drilled hole already on the board: via barrels (whatever their + // layer span) and through-hole pad drills. Ids continue the span space. + let mut holes: Vec = Vec::new(); + let mut push_hole = |center: Vec2, radius: f64, net: String| { + let id = live.len(); + live.push(true); + is_hole.push(true); + bounds.push([ + center.x - radius, + center.y - radius, + center.x + radius, + center.y + radius, + ]); + holes.push(SessionHole { + id, + center, + radius, + net, + }); + }; + for via in &pcb.vias { + push_hole(via.position, via.drill / 2.0, via.net.clone()); + } + for fp in &pcb.footprints { + for pad in &fp.pads { + let Some(drill) = &pad.drill else { continue }; + push_hole( + crate::geometry::pad_world_position(fp, pad), + drill.diameter / 2.0, + pad.net.clone().unwrap_or_default(), + ); + } + } // Dirty grid over the board outline ∪ existing copper, with margin. let mut lo = [f64::INFINITY; 2]; let mut hi = [f64::NEG_INFINITY; 2]; @@ -272,6 +372,26 @@ impl RouteSession { .values() .copied() .fold(default_clearance, f64::max); + // net → via drill from its class, mirroring how the router's vias are + // sized on write-back (see `router::legalize::via_geom_for`). + let default_via_drill = pcb.rules.default_rules.via_drill; + let mut net_via_drill: HashMap = HashMap::new(); + for (class, nets) in &pcb.rules.net_class_assignments { + let Some(c) = pcb.rules.class_rules.iter().find(|c| c.name == *class) else { + continue; + }; + for n in nets { + net_via_drill.insert(n.clone(), c.via_drill); + } + } + // Broadphase reach: the biggest drill radius any existing hole has or + // any net class can mint. + let max_hole_radius = net_via_drill + .values() + .chain(std::iter::once(&default_via_drill)) + .map(|d| d / 2.0) + .chain(holes.iter().map(|h| h.radius)) + .fold(0.0_f64, f64::max); Self { tree: RTree::bulk_load(session_elems), live, @@ -289,6 +409,13 @@ impl RouteSession { default_clearance, max_clearance, net_width: build_net_trace_width_map(pcb), + holes: RTree::bulk_load(holes), + is_hole, + holes_dead: 0, + hole_to_hole: pcb.rules.hole_to_hole, + max_hole_radius, + net_via_drill, + default_via_drill, bounds, dirty, change_epoch: 0, @@ -322,7 +449,8 @@ impl RouteSession { self.net_width.get(net).copied().unwrap_or(fallback) } - /// Number of live (non-tombstoned) copper spans. + /// Number of live (non-tombstoned) spans — copper elements and drilled + /// holes alike. pub fn len(&self) -> usize { self.live.iter().filter(|&&l| l).count() } @@ -336,6 +464,7 @@ impl RouteSession { pub fn commit(&mut self, elem: CopperElement) -> SpanId { let id = self.live.len(); self.live.push(true); + self.is_hole.push(false); let bbox = [elem.min[0], elem.min[1], elem.max[0], elem.max[1]]; self.bounds.push(bbox); self.change_epoch += 1; @@ -353,13 +482,20 @@ impl RouteSession { return false; } self.live[id] = false; - self.dead += 1; let b = self.bounds[id]; self.change_epoch += 1; self.dirty .mark([b[0], b[1]], [b[2], b[3]], self.change_epoch); - if self.dead * 2 > self.tree.size() { - self.compact(); + if self.is_hole[id] { + self.holes_dead += 1; + if self.holes_dead * 2 > self.holes.size() { + self.compact_holes(); + } + } else { + self.dead += 1; + if self.dead * 2 > self.tree.size() { + self.compact(); + } } true } @@ -372,6 +508,104 @@ impl RouteSession { self.dead = 0; } + /// Rebuild the hole index without tombstoned holes. Ids are preserved. + fn compact_holes(&mut self) { + let live = &self.live; + let kept: Vec = self.holes.iter().filter(|h| live[h.id]).cloned().collect(); + self.holes = RTree::bulk_load(kept); + self.holes_dead = 0; + } + + /// The drill diameter (mm) a via on `net` gets from its net class — the + /// same size [`crate::router`] writes back to the board. + pub fn via_drill_for(&self, net: &str) -> f64 { + self.net_via_drill + .get(net) + .copied() + .unwrap_or(self.default_via_drill) + } + + /// Commit a drilled hole (a via barrel) at `center` with drill diameter + /// `drill` on `net`, returning its [`SpanId`]. Remove it with + /// [`RouteSession::remove`] like any other span. + pub fn commit_hole(&mut self, center: Vec2, drill: f64, net: &str) -> SpanId { + let id = self.live.len(); + let radius = drill / 2.0; + self.live.push(true); + self.is_hole.push(true); + let bbox = [ + center.x - radius, + center.y - radius, + center.x + radius, + center.y + radius, + ]; + self.bounds.push(bbox); + self.change_epoch += 1; + self.dirty + .mark([bbox[0], bbox[1]], [bbox[2], bbox[3]], self.change_epoch); + self.max_hole_radius = self.max_hole_radius.max(radius); + self.holes.insert(SessionHole { + id, + center, + radius, + net: net.to_string(), + }); + id + } + + /// Probe a candidate drilled hole (drill diameter `drill`, mm) at `center` + /// on `net` against every live hole of a *different* net — vias and + /// through-hole pads alike — **regardless of layer span**. + /// + /// Mirrors [`crate::drc`]'s `check_hole_to_hole` exactly: a pair is illegal + /// when `center_dist - r_a - r_b < rules.hole_to_hole - 1e-6`. Blind and + /// buried vias on disjoint spans share no copper layer, so + /// [`RouteSession::probe`] never compares them — this is the check that + /// keeps their barrels from colliding. + /// + /// Same-net holes are not blockers here: coincident same-net via drills are + /// the merge case, resolved by the router's post-route legalization rather + /// than by refusing the placement. Net ties are *not* exempt — a tie is an + /// electrical exemption and drills are mechanical, exactly as the DRC pass + /// treats them. + pub fn probe_hole(&self, center: Vec2, drill: f64, net: &str) -> HoleProbeResult { + let r = drill / 2.0; + let reach = r + self.hole_to_hole + self.max_hole_radius; + let lo = [center.x - reach, center.y - reach]; + let hi = [center.x + reach, center.y + reach]; + + let mut min_gap = f64::INFINITY; + let mut blocker = None; + for h in self + .holes + .locate_in_envelope_intersecting(&AABB::from_corners(lo, hi)) + { + if !self.live[h.id] || h.net == net { + continue; + } + let (dx, dy) = (center.x - h.center.x, center.y - h.center.y); + let gap = (dx * dx + dy * dy).sqrt() - r - h.radius; + if gap < min_gap { + min_gap = gap; + blocker = Some((h.id, h.net.clone())); + } + } + // `>=` rather than the DRC's negated `<`: min_gap is INFINITY when + // nothing is near, never NaN, so the two agree on every real input. + let legal = min_gap >= self.hole_to_hole - 1e-6; + HoleProbeResult { + legal, + min_gap, + blocker: if legal { None } else { blocker }, + } + } + + /// [`RouteSession::probe_hole`] for a via on `net`, sized from its net + /// class — the shape every via-placement site in the router uses. + pub fn probe_via_hole(&self, center: Vec2, net: &str) -> HoleProbeResult { + self.probe_hole(center, self.via_drill_for(net), net) + } + /// Axis-aligned bounding boxes (`(min, max)` corners) of every live copper /// element on `layer` belonging to a net other than `net`, restricted to the /// query box `[lo, hi]`. @@ -824,6 +1058,70 @@ mod tests { assert!(r.legal, "net-tied copper must be exempt"); } + #[test] + fn hole_probe_mirrors_drc_and_ignores_layer_span() { + // rules().hole_to_hole is 0.5 and the default via drill 0.4: two vias + // 0.7mm apart leave a 0.3mm gap — illegal on any pair of layer spans. + let mut session = RouteSession::from_pcb(&empty_pcb()); + let a = Vec2::new(50.0, 50.0); + let id = session.commit_hole(a, 0.4, "A"); + + let near = Vec2::new(50.7, 50.0); + let r = session.probe_hole(near, 0.4, "B"); + assert!(!r.legal); + assert!((r.min_gap - 0.3).abs() < 1e-9, "gap was {}", r.min_gap); + assert_eq!(r.blocker.map(|(_, n)| n).as_deref(), Some("A")); + + // Exactly at the rule (0.9mm centers → 0.5mm gap): legal. + assert!(session.probe_hole(Vec2::new(50.9, 50.0), 0.4, "B").legal); + // Same net is never a blocker — coincident same-net drills are the + // router's merge case, not a placement refusal. + assert!(session.probe_hole(near, 0.4, "A").legal); + + // Rip-up frees the hole again, so re-routing can reuse the spot. + assert!(session.remove(id)); + assert!(session.probe_hole(near, 0.4, "B").legal); + } + + #[test] + fn hole_probe_sees_through_hole_pads() { + // A drilled pad is a hole like any other: the DRC compares vias + // against pad drills, so the router's oracle must too. + let mut pcb = empty_pcb(); + pcb.footprints.push(Footprint { + reference: "J1".into(), + value: "conn".into(), + footprint_name: "TH".into(), + position: Vec2::new(50.0, 50.0), + rotation: 0.0, + front: true, + pads: vec![Pad { + number: "1".into(), + pad_type: PadType::THT, + shape: PadShape::Circle { diameter: 1.6 }, + position: Vec2::new(0.0, 0.0), + rotation: 0.0, + drill: Some(DrillSpec { + diameter: 1.0, + oval: false, + oval_height: None, + }), + net: Some("VCC".into()), + layers: vec![PcbLayer::FCu], + }], + graphics: vec![], + model_3d: None, + properties: Default::default(), + }); + let session = RouteSession::from_pcb(&pcb); + // 1.0mm from the pad center: gap = 1.0 - 0.5 - 0.2 = 0.3 < 0.5. + assert!(!session.probe_hole(Vec2::new(51.0, 50.0), 0.4, "SIG").legal); + // 1.3mm away: gap = 0.6 — clear. + assert!(session.probe_hole(Vec2::new(51.3, 50.0), 0.4, "SIG").legal); + // Same net as the pad: not a blocker. + assert!(session.probe_hole(Vec2::new(51.0, 50.0), 0.4, "VCC").legal); + } + #[test] fn ids_stay_stable_across_compaction() { let mut session = RouteSession::from_pcb(&empty_pcb()); From 779a9ecd5e599276b600169c68a6db15bbd5518a Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 21:15:29 -0400 Subject: [PATCH 2/2] style: rustfmt the via hole-to-hole changes Co-Authored-By: Claude Opus 5 --- crates/vcad-ecad-pcb/src/router/auto.rs | 5 ++++- crates/vcad-ecad-pcb/src/router/complete.rs | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/vcad-ecad-pcb/src/router/auto.rs b/crates/vcad-ecad-pcb/src/router/auto.rs index 868c45875..1b2a63282 100644 --- a/crates/vcad-ecad-pcb/src/router/auto.rs +++ b/crates/vcad-ecad-pcb/src/router/auto.rs @@ -3331,7 +3331,10 @@ mod tests { // 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 }; + let disc = CopperGeom::Disc { + center: b, + r: via_r, + }; assert!( [PcbLayer::In2Cu, PcbLayer::BCu] .iter() diff --git a/crates/vcad-ecad-pcb/src/router/complete.rs b/crates/vcad-ecad-pcb/src/router/complete.rs index 831f21742..73932d4d6 100644 --- a/crates/vcad-ecad-pcb/src/router/complete.rs +++ b/crates/vcad-ecad-pcb/src/router/complete.rs @@ -275,10 +275,7 @@ pub fn route_window_complete( // Via edge: disc on both spanned (adjacent) layers. debug_assert_eq!(ca, cb); let center = grid.world(ca); - let disc = CopperGeom::Disc { - center, - r: via_r, - }; + let disc = CopperGeom::Disc { center, r: via_r }; conns.iter().zip(&clearances).all(|((net, _, _), &clr)| { // Drill barrels ignore layer spans — check the hole rule too. session.probe_via_hole(center, net).legal