From 2da37c45565ddfb1c399dad3c6462cb69935f48b Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 23:20:26 -0400 Subject: [PATCH] feat(router): close the CM5 verdict's last unknowns with per-connection certificates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verdict ladder ended at "16 proved-infeasible + 27 unknown" for reasons that were budget and discretization artifacts, not physics. It now reaches zero unknowns on the best CM5 board — 44 routed / 51 proved-infeasible / 0 unknown, converging to a fixed point on a second pass (45 proved, 0 newly routable) — and every certificate names the copper forming its cut. What closes them: - k = 1 is decided by reachability, not by the exhaustive DFS. A lone connection cannot trip an expansion budget: BFS returns a shortest path or an exhausted reachable component, which *is* the proof. This is what makes splitting a cluster terminate. - A cheap sequential-BFS witness pass runs before the DFS for joint instances. The DFS's first descent is a greedy walk, not a shortest path, so on a wide window it could wander for millions of expansions before its first completion — the dominant source of unknowns. - WindowBudget couples the expansion budget to a cells-per-axis cap, so a wider window keeps its pitch at the width+separation floor instead of coarsening until unrelated terminals collide in one cell. - The driver escalates instead of accepting an unknown: joint at 1x/5x/25x budget, then per connection in its own 2/8/20 mm window. A joint infeasibility is no longer charged to the individual connections — it says only that the k cannot all be routed at once, so each earns its own verdict. - The pitch is pair-aware: twins in one window are spaced by their declared gap, restoring "distinct cells ⇒ mutually legal" for pairs. Certificates got sharper, not weaker: the severed case reports whichever terminal's pocket is smaller, with the enclosing cut's size, extent, and the nets holding it; the walled-in case names the pad's layers and its blockers. Three legality gaps that let "routed" copper fail the board are now closed in the session oracle, which is what keeps the new copper DRC-clean: - probe_drill / commit_drill: hole-to-hole across layer spans and nets. Two vias on disjoint spans share no layer, so no layer-scoped copper probe ever compared them — they landed as drill collisions. - Terminals can be pinned to the layers their net actually has copper on, with a probed dog-bone escape (stub on the pad's layer, barrel checked on every layer it spans) when the search wants another layer. Stubs no longer float on an inner layer with nothing joining them to the pad. - set_strict_pair_coupling scores the intra-pair gap the way the DRC does, pads and via annuli included, with the DRC's 1.5 mm pad-breakout exemption mirrored. Opt-in: it is stricter than the geometry the pair router realizes today, so the verdict driver turns it on and pays in routability. Board DRC vs the same input: Clearance 74 (baseline floor, unchanged), Short 1315 (unchanged), MinTraceWidth 69 (unchanged), HoleToHole 0 (unchanged), UnconnectedNet 252 -> 223. NetIslands 18 -> 30 is the accounting shift for 19 nets that had no copper at all and now carry partial copper; an independent connectivity check finds zero floating groups — every committed path is galvanically attached to its net's pads or existing copper. Co-Authored-By: Claude Opus 5 --- ...uter-drill-aware-vias-pad-layer-stubs.json | 10 + crates/vcad-ecad-pcb/examples/cm5_verdict.rs | 675 ++++++++++--- crates/vcad-ecad-pcb/src/router/complete.rs | 912 ++++++++++++++++-- crates/vcad-ecad-pcb/src/session.rs | 266 ++++- 4 files changed, 1650 insertions(+), 213 deletions(-) create mode 100644 changelog/entries/2026-07-24-router-drill-aware-vias-pad-layer-stubs.json diff --git a/changelog/entries/2026-07-24-router-drill-aware-vias-pad-layer-stubs.json b/changelog/entries/2026-07-24-router-drill-aware-vias-pad-layer-stubs.json new file mode 100644 index 000000000..facac481e --- /dev/null +++ b/changelog/entries/2026-07-24-router-drill-aware-vias-pad-layer-stubs.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-24-router-drill-aware-vias-pad-layer-stubs", + "version": "0.9.4", + "date": "2026-07-24", + "category": "fix", + "title": "Router vias clear drills; stubs leave pads on the pad's layer", + "summary": "The routing oracle now checks hole-to-hole spacing across layer spans (two vias on disjoint spans no longer collide in the drill file), probes a via barrel on every layer it spans, and can pin a connection's terminals so stubs leave a pad on a layer the pad is actually on instead of floating on an inner layer.", + "features": ["pcb", "autorouter", "drc"], + "mcpTools": ["route_nets", "run_drc", "validate_for_fab"] +} diff --git a/crates/vcad-ecad-pcb/examples/cm5_verdict.rs b/crates/vcad-ecad-pcb/examples/cm5_verdict.rs index 1fadf4c4e..8962732cd 100644 --- a/crates/vcad-ecad-pcb/examples/cm5_verdict.rs +++ b/crates/vcad-ecad-pcb/examples/cm5_verdict.rs @@ -1,22 +1,80 @@ //! Verdict driver: put the still-unrouted connections of a routed board in //! front of the COMPLETE window router, cluster by cluster, and demand an -//! answer — Routed (commit-quality paths), ProvedInfeasible (bottleneck-cut -//! certificate), or BudgetExhausted (honest unknown). The campaign's closing -//! argument: every connection ends accounted for. +//! answer — Routed (commit-quality paths) or ProvedInfeasible (bottleneck-cut +//! certificate). The campaign's closing argument: every connection ends +//! accounted for, and nothing ends "unknown". +//! +//! The ladder, per cluster, escalates rather than accepting an unknown: +//! +//! 1. **joint window, base budget** — the k connections decided together; +//! 2. **joint window, 5× then 25× budget**, cells-per-axis raised with the last +//! rung so a wider search keeps its pitch at the `width + separation` floor +//! instead of coarsening until unrelated terminals collide in one cell; +//! 3. **per-connection endgame** — whatever is left is decided one connection at +//! a time in its own window: 2 mm, then 8 mm, then 20 mm of margin, each with +//! a cell budget that holds the pitch at that floor. A lone connection is +//! settled by reachability, which is exact and cannot trip a budget: it +//! routes, or its severed reachable component *is* the proof. +//! +//! Splitting is not a fallback for weak proofs, it is what makes the +//! certificates *per connection*: a joint infeasibility says only that the k +//! cannot all be routed at once, so it is never charged to the individual +//! connections — they each get their own verdict. And because each rung searches +//! a strictly larger space than the last, the certificate that survives to the +//! end is the strongest one available. +//! +//! Everything commits fail-closed through the session oracle — traces, via +//! barrels on every layer they span, drills against the hole-to-hole rule, and +//! diff-pair coupling scored the way the board's DRC scores it. //! //! ```bash //! cargo run --release -p vcad-ecad-pcb --example cm5_verdict -- routed.pcb.json [budget] [out.pcb.json] [max_cluster] //! ``` +//! +//! `budget` is DFS expansions; a value below 1000 is read as a multiplier of +//! 1e6 expansions (so `3` and `3000000` mean the same thing). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use vcad_ecad_pcb::ratsnest::{compute_ratsnest, NetConnection, Netlist, NetlistNet}; -use vcad_ecad_pcb::router::complete::{route_window_complete, CompleteOutcome}; +use vcad_ecad_pcb::router::complete::{ + route_window_complete_pinned, CompleteOutcome, TerminalLayers, ViaClass, WindowBudget, +}; use vcad_ecad_pcb::session::RouteSession; use vcad_ecad_pcb::spatial::{CopperElement, CopperGeom}; -use vcad_ir::ecad::Pcb; +use vcad_ir::ecad::{Pcb, PcbLayer}; use vcad_ir::Vec2; -type Cluster = (Vec2, Vec2, Vec<(String, Vec2, Vec2)>); +/// A connection awaiting a verdict. +#[derive(Clone)] +struct Conn { + net: String, + from: Vec2, + to: Vec2, +} + +impl Conn { + fn tuple(&self) -> (String, Vec2, Vec2) { + (self.net.clone(), self.from, self.to) + } +} + +/// Cluster = merged window plus the connections decided in it. +struct Cluster { + lo: Vec2, + hi: Vec2, + conns: Vec, +} + +/// Joint rungs: `(budget multiplier, cells-per-axis cap)`. The cap rises with +/// the budget — raising the window without raising the cell count would only +/// coarsen the pitch until unrelated terminals collide. +const JOINT_RUNGS: [(usize, usize); 3] = [(1, 48), (5, 48), (25, 96)]; + +/// Per-connection rungs: `(window margin in mm, cells-per-axis cap)`. Each rung +/// searches a strictly larger space than the last, so escalating can only turn +/// an infeasibility into a routing — and the certificate that survives to the +/// end is the strongest one (the widest space exhausted). +const SINGLE_RUNGS: [(f64, usize); 3] = [(2.0, 160), (8.0, 320), (20.0, 640)]; fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) @@ -25,14 +83,15 @@ fn main() { let mut args = std::env::args().skip(1); let path = args .next() - .expect("usage: cm5_verdict [budget]"); + .expect("usage: cm5_verdict [budget] [out.pcb.json] [max_cluster]"); let budget: usize = args .next() - .and_then(|s| s.parse().ok()) + .and_then(|s| s.parse::().ok()) + .map(|b| if b < 1000.0 { b * 1e6 } else { b } as usize) .unwrap_or(5_000_000); let out_json = args.next(); let max_cluster: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(6); - let mut pcb: Pcb = + let pcb: Pcb = serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse"); // Unrouted connections = ratsnest over the routed board. @@ -68,180 +127,484 @@ fn main() { rats.retain(|l| !plane_nets.contains(l.net.as_str())); println!("unrouted connections (plane nets excluded): {}", rats.len()); - let mut session = RouteSession::from_pcb(&pcb); - let layers: Vec<_> = pcb - .stackup - .layers - .iter() - .map(|l| l.layer) - .filter(|l| l.is_copper()) - .collect(); - let width = pcb.rules.default_rules.trace_width; - // Per-net class widths: SI-class nets must be routed and committed at - // their class width (the DRC's MinTraceWidth rule is per-net) — a joint - // path at default width would land 500+ width violations on a board - // whose pairs are classed at 0.2 mm. - let net_width: std::collections::HashMap = pcb - .rules - .class_rules - .iter() - .flat_map(|rule| { - pcb.rules - .net_class_assignments - .get(&rule.name) - .into_iter() - .flatten() - .map(move |net| (net.clone(), rule.trace_width)) - }) - .collect(); + let clusters = cluster(&rats, max_cluster); + println!("clusters: {}", clusters.len()); + + let mut driver = Driver::new(pcb, budget); + for c in &clusters { + driver.resolve_cluster(c); + } + println!( + "\n== VERDICT: routed {} / proved-infeasible {} / unknown {} ==", + driver.routed, driver.proved, driver.unknown + ); + if let Some(out) = out_json { + std::fs::write(&out, serde_json::to_string(&driver.pcb).expect("serialize")) + .expect("write"); + eprintln!("wrote {out}"); + } +} - // Cluster connections whose bboxes (inflated 2mm) overlap. Two rules keep - // the certificates honest: a cluster never holds two connections of the - // same net (per-connection node-disjointness can't model same-net cell - // sharing), and the merged window is capped — a big coalesced window - // coarsens the grid pitch (MAX_AXIS_CELLS) until unrelated terminals - // artificially collide. +/// Cluster connections whose bboxes (inflated 2 mm) overlap. Two rules keep the +/// joint instances tractable: a cluster never holds two connections of the same +/// net (per-connection node-disjointness can't model same-net cell sharing), and +/// the merged window is capped, because the cost of a joint instance grows with +/// its window while the *benefit* of merging does not. The cap is not what keeps +/// the pitch honest any more — the per-rung cell budget does that, so a window +/// stays at its `width + separation` floor however large it gets. +fn cluster(rats: &[vcad_ecad_pcb::ratsnest::RatsnestLine], max_cluster: usize) -> Vec { const MAX_WINDOW_MM: f64 = 20.0; let mut clusters: Vec = Vec::new(); - 'c: for l in &rats { + 'c: for l in rats { let (lo, hi) = ( Vec2::new(l.from.x.min(l.to.x) - 2.0, l.from.y.min(l.to.y) - 2.0), Vec2::new(l.from.x.max(l.to.x) + 2.0, l.from.y.max(l.to.y) + 2.0), ); - for (clo, chi, cc) in clusters.iter_mut() { - let merged_w = (chi.x.max(hi.x) - clo.x.min(lo.x)).abs(); - let merged_h = (chi.y.max(hi.y) - clo.y.min(lo.y)).abs(); - if lo.x <= chi.x - && clo.x <= hi.x - && lo.y <= chi.y - && clo.y <= hi.y - && cc.len() < max_cluster + let conn = Conn { + net: l.net.clone(), + from: l.from, + to: l.to, + }; + for c in clusters.iter_mut() { + let merged_w = (c.hi.x.max(hi.x) - c.lo.x.min(lo.x)).abs(); + let merged_h = (c.hi.y.max(hi.y) - c.lo.y.min(lo.y)).abs(); + if lo.x <= c.hi.x + && c.lo.x <= hi.x + && lo.y <= c.hi.y + && c.lo.y <= hi.y + && c.conns.len() < max_cluster && merged_w <= MAX_WINDOW_MM && merged_h <= MAX_WINDOW_MM - && cc.iter().all(|(n, _, _)| n != &l.net) + && c.conns.iter().all(|k| k.net != conn.net) { - clo.x = clo.x.min(lo.x); - clo.y = clo.y.min(lo.y); - chi.x = chi.x.max(hi.x); - chi.y = chi.y.max(hi.y); - cc.push((l.net.clone(), l.from, l.to)); + c.lo.x = c.lo.x.min(lo.x); + c.lo.y = c.lo.y.min(lo.y); + c.hi.x = c.hi.x.max(hi.x); + c.hi.y = c.hi.y.max(hi.y); + c.conns.push(conn); continue 'c; } } - clusters.push((lo, hi, vec![(l.net.clone(), l.from, l.to)])); + clusters.push(Cluster { + lo, + hi, + conns: vec![conn], + }); } - println!("clusters: {}", clusters.len()); + clusters +} + +/// Distance from `p` to the segment `a`–`b` (centreline, mm). +fn point_on_segment(p: Vec2, a: Vec2, b: Vec2) -> f64 { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let len2 = dx * dx + dy * dy; + let t = if len2 < 1e-18 { + 0.0 + } else { + (((p.x - a.x) * dx + (p.y - a.y) * dy) / len2).clamp(0.0, 1.0) + }; + ((p.x - a.x - t * dx).powi(2) + (p.y - a.y - t * dy).powi(2)).sqrt() +} + +/// Board state plus the running verdict tally. +struct Driver { + pcb: Pcb, + session: RouteSession, + layers: Vec, + /// Per-net class trace width: SI-class nets must be searched and committed + /// at their class width (the DRC's MinTraceWidth rule is per-net), so a + /// joint path at default width would land hundreds of width violations on a + /// board whose pairs are classed at 0.2 mm. + net_width: HashMap, + default_width: f64, + budget: usize, + routed: usize, + proved: usize, + unknown: usize, +} - let (mut routed, mut proved, mut unknown) = (0usize, 0usize, 0usize); - for (lo, hi, conns) in &clusters { - let names: Vec<&str> = conns.iter().map(|c| c.0.as_str()).collect(); - // Search at the widest class width in the cluster (conservative: - // guarantees the found corridors fit every member's committed width). - let cluster_width = conns +impl Driver { + fn new(pcb: Pcb, budget: usize) -> Self { + let mut session = RouteSession::from_pcb(&pcb); + // Judge diff-pair coupling the way the board's DRC will, pads and via + // annuli included. Costs a few routings; buys copper that cannot land a + // pair-gap violation. + session.set_strict_pair_coupling(true); + let layers: Vec<_> = pcb + .stackup + .layers .iter() - .map(|(n, _, _)| net_width.get(n).copied().unwrap_or(width)) - .fold(width, f64::max); - match route_window_complete(&session, (*lo, *hi), &layers, conns, cluster_width, budget) { - CompleteOutcome::Routed(paths) => { - // Fail-closed: the window router's coarse grid can hide - // sub-clearance gaps its pitch cannot see, and joint paths - // do not know the intra-pair gap rule. Probe every segment - // through the (pair-aware) session before trusting the - // routing; a path the oracle rejects downgrades the cluster - // to an honest unknown. - // Probe-then-commit PER PATH, in order: cluster paths are - // node-disjoint on the coarse window grid, but the grid pitch - // can hide sub-clearance gaps BETWEEN two paths of the same - // cluster (observed: two same-cluster diagonals overlapping - // at 0.000mm). Probing each path against the session AFTER - // its clustermates committed makes mutual legality exact; a - // path that fails downgrades only itself to unknown. - let mut cluster_routed = 0usize; - for ((net, _, _), path) in conns.iter().zip(&paths) { - let w = net_width.get(net).copied().unwrap_or(width); - let legal = path.iter().all(|&(a, b, l)| { - let g = vcad_ecad_pcb::spatial::CopperGeom::Segment { - a, - b, - half_w: w / 2.0, - }; - session.probe(&g, l, net, session.clearance_for(net)).legal - }); - if !legal { - unknown += 1; - println!("UNKNOWN [{net:?}] (path failed oracle probe)"); + .map(|l| l.layer) + .filter(|l| l.is_copper()) + .collect(); + let net_width: HashMap = pcb + .rules + .class_rules + .iter() + .flat_map(|rule| { + pcb.rules + .net_class_assignments + .get(&rule.name) + .into_iter() + .flatten() + .map(move |net| (net.clone(), rule.trace_width)) + }) + .collect(); + let default_width = pcb.rules.default_rules.trace_width; + Self { + pcb, + session, + layers, + net_width, + default_width, + budget, + routed: 0, + proved: 0, + unknown: 0, + } + } + + /// The copper layers each terminal of `conn` may attach on: the layers where + /// the net *already has copper* at that endpoint. + /// + /// A ratsnest line joins two copper groups, so an endpoint is a pad on some + /// layers, or a point on an existing trace or via of the net — and only + /// those layers are electrically live there. A stub that starts anywhere + /// else is dangling: nothing joins it to the group, and the board gains a + /// net island instead of a connection. + fn terminal_layers(&self, conn: &Conn) -> TerminalLayers { + let at = |p: Vec2| -> Vec { + let mut out: Vec = Vec::new(); + for fp in &self.pcb.footprints { + for pad in &fp.pads { + if pad.net.as_deref() != Some(conn.net.as_str()) { continue; } - cluster_routed += 1; - let w = net_width.get(net).copied().unwrap_or(width); - for (a, b, layer) in path { - session.commit(CopperElement { - min: [a.x.min(b.x) - w, a.y.min(b.y) - w], - max: [a.x.max(b.x) + w, a.y.max(b.y) + w], - net: net.clone(), - layer: *layer, - geom: CopperGeom::Segment { - a: *a, - b: *b, - half_w: w / 2.0, - }, - }); - pcb.traces.push(vcad_ir::ecad::Trace { - start: *a, - end: *b, - width: w, - layer: *layer, - net: net.clone(), - source: None, - }); + let w = vcad_ecad_pcb::geometry::pad_world_position(fp, pad); + if (w.x - p.x).abs() < 1e-3 && (w.y - p.y).abs() < 1e-3 { + out.extend(pad.layers.iter().filter(|l| self.layers.contains(l))); } - for w in path.windows(2) { - let (_, b0, l0) = w[0]; - let (a1, _, l1) = w[1]; - if l0 != l1 && (b0.x - a1.x).abs() < 1e-9 && (b0.y - a1.y).abs() < 1e-9 { - let r = pcb.rules.default_rules.via_diameter / 2.0; - for layer in [l0, l1] { - session.commit(CopperElement { - min: [b0.x - r, b0.y - r], - max: [b0.x + r, b0.y + r], - net: net.clone(), - layer, - geom: CopperGeom::Disc { center: b0, r }, - }); + } + } + for trace in &self.pcb.traces { + if trace.net != conn.net { + continue; + } + if point_on_segment(p, trace.start, trace.end) <= trace.width / 2.0 + 1e-3 { + out.push(trace.layer); + } + } + for via in &self.pcb.vias { + if via.net != conn.net { + continue; + } + let d = ((via.position.x - p.x).powi(2) + (via.position.y - p.y).powi(2)).sqrt(); + if d <= via.diameter / 2.0 + 1e-3 { + out.extend(self.span(via.start_layer, via.end_layer)); + } + } + out.retain(|l| l.is_copper()); + out.sort_by_key(|l| { + self.layers + .iter() + .position(|s| s == l) + .unwrap_or(usize::MAX) + }); + out.dedup(); + out + }; + TerminalLayers { + from: at(conn.from), + to: at(conn.to), + } + } + + /// The via geometry this driver commits — handed to the router so its + /// legality model is the same one the commit will enforce. + fn via_class(&self) -> ViaClass { + ViaClass { + pad_diameter: self.pcb.rules.default_rules.via_diameter, + drill: self.pcb.rules.default_rules.via_drill, + } + } + + fn width_of(&self, net: &str) -> f64 { + self.net_width + .get(net) + .copied() + .unwrap_or(self.default_width) + } + + /// Search width for a set of connections: the widest class width among them + /// (conservative — the corridors found then fit every member's committed + /// width). + fn search_width(&self, conns: &[Conn]) -> f64 { + conns + .iter() + .map(|c| self.width_of(&c.net)) + .fold(self.default_width, f64::max) + } + + fn resolve_cluster(&mut self, cluster: &Cluster) { + let names: Vec<&str> = cluster.conns.iter().map(|c| c.net.as_str()).collect(); + let mut pending = cluster.conns.clone(); + // Joint rungs only earn their keep when there is something joint to + // decide; a lone connection goes straight to the exact endgame. + if pending.len() > 1 { + let width = self.search_width(&pending); + for (mul, cells) in JOINT_RUNGS { + let limits = + WindowBudget::new(self.budget.saturating_mul(mul)).with_max_axis_cells(cells); + let conns: Vec<(String, Vec2, Vec2)> = pending.iter().map(|c| c.tuple()).collect(); + let pins: Vec = + pending.iter().map(|c| self.terminal_layers(c)).collect(); + match route_window_complete_pinned( + &self.session, + (cluster.lo, cluster.hi), + &self.layers, + &conns, + &pins, + width, + Some(self.via_class()), + limits, + ) { + CompleteOutcome::Routed(paths) => { + // Probe-then-commit PER PATH, in order: cluster paths + // are node-disjoint on the window grid, but the pitch + // can hide sub-clearance gaps BETWEEN two paths of the + // same cluster. Probing each path against the session + // AFTER its clustermates committed makes mutual + // legality exact; a path the oracle rejects drops to + // the per-connection endgame rather than to an unknown. + let mut rejected = Vec::new(); + let mut committed = 0usize; + for (conn, path) in pending.iter().zip(&paths) { + if self.commit_if_legal(conn, path) { + committed += 1; + self.routed += 1; + } else { + rejected.push(conn.clone()); } - pcb.vias.push(vcad_ir::ecad::Via { - position: b0, - diameter: pcb.rules.default_rules.via_diameter, - drill: pcb.rules.default_rules.via_drill, - start_layer: l0, - end_layer: l1, - net: net.clone(), - source: None, - }); } + if committed > 0 { + println!( + "ROUTED {names:?} ({committed}/{} committed jointly at \ + {mul}x budget)", + pending.len() + ); + } + pending = rejected; + break; + } + CompleteOutcome::ProvedInfeasible { reason } => { + // A joint proof is NOT a per-connection proof: it says + // the k cannot all be routed at once. Charge it to + // nobody and let each connection earn its own verdict. + println!("JOINT-NO {names:?}: {reason}"); + break; + } + CompleteOutcome::BudgetExhausted => continue, + } + } + } + for conn in std::mem::take(&mut pending) { + self.resolve_single(&conn); + } + } + + /// Decide one connection on its own. Reachability is exact for k = 1, so + /// every rung answers definitively; escalation exists to make a *routing* + /// more likely (wider window, finer pitch) and, failing that, to make the + /// surviving certificate the strongest one available. + fn resolve_single(&mut self, conn: &Conn) { + let width = self.width_of(&conn.net); + let mut last: Option<(bool, String)> = None; + for (margin, cells) in SINGLE_RUNGS { + let window = ( + Vec2::new( + conn.from.x.min(conn.to.x) - margin, + conn.from.y.min(conn.to.y) - margin, + ), + Vec2::new( + conn.from.x.max(conn.to.x) + margin, + conn.from.y.max(conn.to.y) + margin, + ), + ); + match route_window_complete_pinned( + &self.session, + window, + &self.layers, + &[conn.tuple()], + &[self.terminal_layers(conn)], + width, + Some(self.via_class()), + WindowBudget::new(self.budget).with_max_axis_cells(cells), + ) { + CompleteOutcome::Routed(paths) => { + if self.commit_if_legal(conn, &paths[0]) { + self.routed += 1; + println!("ROUTED [{:?}] (alone, {margin:.0}mm window)", conn.net); + return; } + last = Some(( + false, + format!( + "window path rejected by the fail-closed session oracle \ + ({margin:.0}mm window, {cells} cells/axis)" + ), + )); } - routed += cluster_routed; - if cluster_routed > 0 { - println!( - "ROUTED {names:?} ({cluster_routed}/{} committed)", - conns.len() - ); + CompleteOutcome::ProvedInfeasible { reason } => last = Some((true, reason)), + // k = 1 is decided by reachability, which cannot trip the + // expansion budget — but keep the honest branch anyway. + CompleteOutcome::BudgetExhausted => { + last = Some(( + false, + format!("budget {} exhausted ({margin:.0}mm window)", self.budget), + )) } } - CompleteOutcome::ProvedInfeasible { reason } => { - proved += conns.len(); - println!("PROVED {names:?}: {reason}"); + } + match last { + Some((true, reason)) => { + self.proved += 1; + println!("PROVED [{:?}]: {reason}", conn.net); + } + Some((false, why)) => { + self.unknown += 1; + println!("UNKNOWN [{:?}] ({why})", conn.net); } - CompleteOutcome::BudgetExhausted => { - unknown += conns.len(); - println!("UNKNOWN {names:?} (budget {budget} exhausted)"); + None => { + self.unknown += 1; + println!("UNKNOWN [{:?}] (no rung reported)", conn.net); } } } - println!("\n== VERDICT: routed {routed} / proved-infeasible {proved} / unknown {unknown} =="); - if let Some(out) = out_json { - std::fs::write(&out, serde_json::to_string(&pcb).expect("serialize")).expect("write"); - eprintln!("wrote {out}"); + + /// Every copper layer a via spanning `a`..`b` passes through, in stack + /// order. A barrel's annulus lands on all of them, so all of them must + /// clear. + fn span(&self, a: PcbLayer, b: PcbLayer) -> Vec { + let idx = |l: PcbLayer| self.layers.iter().position(|s| *s == l).unwrap_or(0); + let (i, j) = (idx(a), idx(b)); + self.layers[i.min(j)..=i.max(j)].to_vec() + } + + /// Probe a candidate path — segments *and* the vias as they will be + /// committed — against the session, and commit it only if every piece is + /// legal. Returns whether the path landed on the board. + fn commit_if_legal(&mut self, conn: &Conn, path: &[(Vec2, Vec2, PcbLayer)]) -> bool { + let net = &conn.net; + let w = self.width_of(net); + let clr = self.session.clearance_for(net); + let via_d = self.pcb.rules.default_rules.via_diameter; + let via_r = via_d / 2.0; + let segs_ok = path.iter().all(|&(a, b, layer)| { + self.session + .probe( + &CopperGeom::Segment { + a, + b, + half_w: w / 2.0, + }, + layer, + net, + clr, + ) + .legal + }); + if !segs_ok { + return false; + } + // Layer transitions become real vias, so they must clear as real vias: + // probing only the traces let via copper land unchecked. Consecutive + // transitions at one point are ONE barrel spanning the whole run — + // emitting them as separate vias would stack drills at zero spacing and + // fail hole-to-hole against itself. + 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 || (b0.x - a1.x).abs() > 1e-9 || (b0.y - a1.y).abs() > 1e-9 { + continue; + } + match vias.last_mut() { + Some((p, _, end)) + if (p.x - b0.x).abs() < 1e-9 && (p.y - b0.y).abs() < 1e-9 && *end == l0 => + { + *end = l1; + } + _ => vias.push((b0, l0, l1)), + } + } + let via_drill = self.pcb.rules.default_rules.via_drill; + let vias_ok = vias.iter().all(|&(p, l0, l1)| { + // The barrel exists on every layer it spans, not just its two ends. + self.span(l0, l1).iter().all(|&layer| { + self.session + .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 + // ...and two vias of THIS path must clear each other, which the + // session cannot judge until they are committed. + && vias.iter().all(|&(q, _, _)| { + let d = ((q.x - p.x).powi(2) + (q.y - p.y).powi(2)).sqrt() - via_drill; + (q.x - p.x).abs() + (q.y - p.y).abs() < 1e-9 + || d >= self.pcb.rules.hole_to_hole - 1e-6 + }) + }); + if !vias_ok { + return false; + } + for &(a, b, layer) in path { + self.session.commit(CopperElement { + min: [a.x.min(b.x) - w, a.y.min(b.y) - w], + max: [a.x.max(b.x) + w, a.y.max(b.y) + w], + net: net.clone(), + layer, + geom: CopperGeom::Segment { + a, + b, + half_w: w / 2.0, + }, + }); + self.pcb.traces.push(vcad_ir::ecad::Trace { + start: a, + end: b, + width: w, + layer, + net: net.clone(), + source: None, + }); + } + for (p, l0, l1) in vias { + for layer in self.span(l0, l1) { + self.session.commit(CopperElement { + min: [p.x - via_r, p.y - via_r], + max: [p.x + via_r, p.y + via_r], + net: net.clone(), + layer, + geom: CopperGeom::Disc { + center: p, + r: via_r, + }, + }); + } + self.session.commit_drill(p, via_drill); + self.pcb.vias.push(vcad_ir::ecad::Via { + position: p, + diameter: via_d, + drill: via_drill, + start_layer: l0, + end_layer: l1, + net: net.clone(), + source: None, + }); + } + true } } diff --git a/crates/vcad-ecad-pcb/src/router/complete.rs b/crates/vcad-ecad-pcb/src/router/complete.rs index da58368b9..fb3a3adb0 100644 --- a/crates/vcad-ecad-pcb/src/router/complete.rs +++ b/crates/vcad-ecad-pcb/src/router/complete.rs @@ -78,7 +78,7 @@ //! search (it ignores which net must pair with which terminal), so it can //! only fire on genuinely infeasible instances. -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use vcad_ir::ecad::PcbLayer; use vcad_ir::Vec2; @@ -86,9 +86,78 @@ use vcad_ir::Vec2; use crate::session::RouteSession; use crate::spatial::CopperGeom; -/// Hard cap on grid cells per axis; the pitch coarsens to fit. Keeps the +/// Default cap on grid cells per axis; the pitch coarsens to fit. Keeps the /// state space honest (≤ 48×48×4 ≈ 9.2k nodes). -const MAX_AXIS_CELLS: usize = 48; +pub const MAX_AXIS_CELLS: usize = 48; + +/// Which copper layers a connection's two terminals may attach on — normally +/// the layers each terminal's pad actually exists on. +/// +/// Without this the search takes the first layer whose cell happens to be free, +/// which on a ten-layer board is usually *not* the pad's layer: the emitted stub +/// then floats on an inner layer with nothing joining it to the pad, and the +/// board grows a net island instead of a connection. Empty means unconstrained +/// (the whole searched stack). +#[derive(Debug, Clone, Default)] +pub struct TerminalLayers { + /// Layers the `from` terminal may attach on. + pub from: Vec, + /// Layers the `to` terminal may attach on. + pub to: Vec, +} + +/// The via the caller will actually commit for a layer change. +/// +/// Supplying it makes the router's legality model identical to the caller's +/// commit rule instead of merely similar: the barrel is probed at its real pad +/// size, the drill is checked against the board's hole-to-hole rule (which no +/// layer-scoped copper probe can see), and the grid pitch is floored so two +/// vias of one routing can never land closer than that rule allows. Without it +/// the router falls back to a pad-size heuristic and leaves drills to the +/// caller — which is where a "routed" path can still fail a fail-closed commit. +#[derive(Debug, Clone, Copy)] +pub struct ViaClass { + /// Via pad (annulus) diameter, mm. + pub pad_diameter: f64, + /// Drilled hole diameter, mm. + pub drill: f64, +} + +/// Search resources the caller may raise without weakening the decision +/// procedure: the DFS expansion budget and the grid's cells-per-axis cap. +/// +/// The two are coupled on purpose. A wide window at a fixed cell cap coarsens +/// the pitch until unrelated terminals collide in one cell and free channels +/// vanish — the discretization, not the copper, then decides the verdict. +/// Raising [`Self::max_axis_cells`] alongside the window keeps the pitch at its +/// `(width + separation)` floor, so the answer stays about the board. +#[derive(Debug, Clone, Copy)] +pub struct WindowBudget { + /// Maximum DFS node expansions before the search reports "unknown". + pub expansions: usize, + /// Cap on grid cells per axis. The pitch never goes *below* the + /// `width + separation` floor, so a generous cap simply stops the pitch + /// from coarsening on a large window. + pub max_axis_cells: usize, +} + +impl WindowBudget { + /// `expansions` at the default cell cap. + pub fn new(expansions: usize) -> Self { + Self { + expansions, + max_axis_cells: MAX_AXIS_CELLS, + } + } + + /// Same budget with a raised cells-per-axis cap. + pub fn with_max_axis_cells(self, max_axis_cells: usize) -> Self { + Self { + max_axis_cells, + ..self + } + } +} /// Outcome of the complete window router. #[derive(Debug, Clone)] @@ -129,6 +198,45 @@ pub fn route_window_complete( width: f64, budget: usize, ) -> CompleteOutcome { + route_window_complete_with( + session, + window, + layers, + conns, + width, + WindowBudget::new(budget), + ) +} + +/// [`route_window_complete`] with explicit search resources — see +/// [`WindowBudget`]. +pub fn route_window_complete_with( + session: &RouteSession, + window: (Vec2, Vec2), + layers: &[PcbLayer], + conns: &[(String, Vec2, Vec2)], + width: f64, + limits: WindowBudget, +) -> CompleteOutcome { + route_window_complete_pinned(session, window, layers, conns, &[], width, None, limits) +} + +/// [`route_window_complete_with`], with each connection's terminals pinned to +/// the layers they may attach on (see [`TerminalLayers`]) and the caller's real +/// via geometry (see [`ViaClass`]). A shorter `terminals` slice than `conns` +/// leaves the remaining connections unconstrained. +#[allow(clippy::too_many_arguments)] +pub fn route_window_complete_pinned( + session: &RouteSession, + window: (Vec2, Vec2), + layers: &[PcbLayer], + conns: &[(String, Vec2, Vec2)], + terminals: &[TerminalLayers], + width: f64, + via: Option, + limits: WindowBudget, +) -> CompleteOutcome { + let budget = limits.expansions; if conns.is_empty() { return CompleteOutcome::Routed(Vec::new()); } @@ -143,16 +251,48 @@ pub fn route_window_complete( .map(|(n, _, _)| session.clearance_for(n)) .collect(); let max_clearance = clearances.iter().cloned().fold(0.0_f64, f64::max); - let grid = WinGrid::new(window, width, max_clearance); + // Pair-aware pitch. Node disjointness is only a *sufficient* legality rule + // when one pitch of separation satisfies every rule that can apply between + // two paths — and between the two legs of a declared differential pair the + // probe demands the intra-pair GAP, which routinely exceeds the base + // clearance. Two twins in one window at clearance pitch route uncoupled and + // then fail the oracle (the fail-closed probe the caller commits through), + // which shows up as an honest-but-avoidable unknown. Spacing the grid by the + // widest gap among the window's twin pairs restores "distinct cells ⇒ + // mutually legal" for pairs, exactly as it already holds for singles. + let mut separation = max_clearance; + for (i, (a, _, _)) in conns.iter().enumerate() { + for (b, _, _) in conns.iter().skip(i + 1) { + if let Some(gap) = session.pair_gap_between(a, b) { + separation = separation.max(gap); + } + } + } + // Second pitch floor: two vias one cell apart must satisfy the board's + // hole-to-hole rule, or a routing this model calls legal lands drill + // collisions the moment it is committed. + let drill_floor = via.map_or(0.0, |v| v.drill + session.hole_to_hole()); + let grid = WinGrid::new( + window, + width, + separation, + drill_floor, + limits.max_axis_cells, + ); let plane = grid.nx * grid.ny; let nl = layers.len(); let n_nodes = plane * nl; let half_w = width / 2.0; - // Via legality is probed as a disc covering the real microvia pad (the - // largest via class this router ever realizes is the adjacent-layer - // microvia) on both spanned layers. Probing smaller than the committed - // pad let verdict copper pass here and fail board DRC by the difference. - let via_r = (width * 1.5).max(0.11); + // Via legality is probed as a disc covering the real committed pad on every + // spanned layer. Probing smaller than the committed pad let verdict copper + // pass here and fail board DRC by the difference. + let via_r = (width * 1.5) + .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) }; // --- Free-node raster (fixed copper only) ---------------------------- // A node is free iff a zero-length capsule (trace centre) at the cell @@ -184,35 +324,137 @@ pub fn route_window_complete( // determinism; the connector from the exact endpoint to the cell centre // must itself probe legal. let mut terms: Vec<(usize, usize)> = Vec::with_capacity(conns.len()); // (src node, dst node) + /// Per-connection escape layers: `Some(pad layer)` when that terminal + /// reaches the grid through a stub-plus-via dog-bone rather than landing on + /// the pad's own layer. + type Escape = (Option, Option); + let mut escapes: Vec = Vec::with_capacity(conns.len()); for (ci, (net, from, to)) in conns.iter().enumerate() { let clr = clearances[ci]; - let attach = |p: Vec2| -> Option { - let cell = grid.snap(p); - let c = grid.world(cell); - (0..nl).find_map(|li| { - let node = li * plane + cell; - let conn_ok = session - .probe( - &CopperGeom::Segment { a: p, b: c, half_w }, - layers[li], - net, - clr, - ) - .legal; - (free[node] && conn_ok).then_some(node) + let pinned = terminals.get(ci); + // Attachment candidates: cells in rings around the snapped one, nearest + // first. A pad on a fine-pitch part often has its own nearest cell + // centre buried in a neighbour's clearance while a cell one step out is + // free; since the pad-to-cell connector is probed exactly either way, + // reaching for that cell is not a cheat — and it is what lets the stub + // stay on the pad's own layer instead of surfacing on an inner one with + // nothing joining it to the pad. + // Reach measured in millimetres, not cells: a dog-bone escape lands + // within about a millimetre of its pad whatever the pitch happens to be. + let attach_ring = ((1.0 / grid.pitch).ceil() as i64).clamp(2, 4); + let attach = |p: Vec2, allowed: &[PcbLayer]| -> Option<(usize, Option)> { + let base = grid.snap(p); + let (bx, by) = ((base % grid.nx) as i64, (base / grid.nx) as i64); + let mut cands: Vec<(i64, usize)> = Vec::new(); + for dy in -attach_ring..=attach_ring { + for dx in -attach_ring..=attach_ring { + let (x, y) = (bx + dx, by + dy); + if x < 0 || y < 0 || x >= grid.nx as i64 || y >= grid.ny as i64 { + continue; + } + cands.push((dx * dx + dy * dy, y as usize * grid.nx + x as usize)); + } + } + cands.sort_unstable(); + let stub_ok = |p: Vec2, c: Vec2, layer: PcbLayer| { + session + .probe(&CopperGeom::Segment { a: p, b: c, half_w }, layer, net, clr) + .legal + }; + // First choice: land directly on a layer the pad is on. Nothing to + // join, nothing to drill. + let direct = cands.iter().find_map(|&(_, cell)| { + let c = grid.world(cell); + (0..nl) + .filter(|&li| allowed.is_empty() || allowed.contains(&layers[li])) + .find_map(|li| { + (free[li * plane + cell] && stub_ok(p, c, layers[li])) + .then_some((li * plane + cell, None)) + }) + }); + if direct.is_some() || allowed.is_empty() { + return direct; + } + // Otherwise escape like a real router does: a stub on the pad's own + // layer to a nearby cell, then a via down to the layer the search + // wants. The whole barrel is probed — pad copper on every layer it + // spans plus the hole-to-hole rule — so the escape is legal by the + // same oracle that will judge the committed board, not by assumption. + cands.iter().find_map(|&(_, cell)| { + let c = grid.world(cell); + if dist(p, c) < 1e-6 { + // Pad centre on the cell centre leaves no stub to carry the + // layer change; take another cell. + return None; + } + let pad_li = (0..nl).find(|&li| allowed.contains(&layers[li]))?; + if !stub_ok(p, c, layers[pad_li]) || !barrel_ok(c) { + return None; + } + (0..nl).find_map(|li| { + if li == pad_li || !free[li * plane + cell] { + return None; + } + let span = if li < pad_li { + li..=pad_li + } else { + pad_li..=li + }; + let barrel = CopperGeom::Disc { + center: c, + r: via_r, + }; + span.clone() + .all(|s| session.probe(&barrel, layers[s], net, clr).legal) + .then_some((li * plane + cell, Some(layers[pad_li]))) + }) }) }; - match (attach(*from), attach(*to)) { - (Some(s), Some(t)) => terms.push((s, t)), - _ => { + let (from_pins, to_pins) = pinned + .map(|t| (t.from.as_slice(), t.to.as_slice())) + .unwrap_or((&[], &[])); + match (attach(*from, from_pins), attach(*to, to_pins)) { + (Some((s, se)), Some((t, te))) => { + terms.push((s, t)); + escapes.push((se, te)); + } + (s, _) => { + // Name the copper that walls the pad in: the cut here is the + // ring of blockers around the terminal itself. + let stuck = if s.is_none() { *from } else { *to }; + let pins = if s.is_none() { from_pins } else { to_pins }; + let on = if pins.is_empty() { + format!("all {nl} copper layers") + } else { + format!("its {} pad layer(s) {pins:?}", pins.len()) + }; + let census = blocking_nets( + session, + &CopperGeom::Segment { + a: stuck, + b: grid.world(grid.snap(stuck)), + half_w, + }, + layers, + net, + clr, + ); return CompleteOutcome::ProvedInfeasible { reason: format!( "terminal of net {net} at ({:.2}, {:.2})/({:.2}, {:.2}) has no \ - clearance-legal grid attachment on any layer — the pad is walled \ - in at the current rules and grid pitch {:.3} mm", - from.x, from.y, to.x, to.y, grid.pitch + clearance-legal grid attachment — the pad at ({:.2}, {:.2}) is \ + walled in on {on} by {} at the current rules and grid pitch \ + {:.3} mm", + from.x, + from.y, + to.x, + to.y, + stuck.x, + stuck.y, + name_census(&census), + grid.pitch ), - } + }; } } } @@ -220,16 +462,19 @@ pub fn route_window_complete( // node-disjoint — a genuine infeasibility at this pitch. Two connections // of the SAME net sharing a cell is not: same-net copper may legally // share space, but this model's per-connection node-disjointness can't - // express that, so the honest answer is unknown, never a proof. + // express that, so the honest answer is unknown, never a proof. A single + // connection whose OWN two terminals snap into one cell is neither: it is + // simply shorter than the pitch, and its path is that one node. { - let mut seen: HashMap = HashMap::new(); + let mut seen: HashMap = HashMap::new(); for (ci, &(s, t)) in terms.iter().enumerate() { for node in [s, t] { match seen.get(&node) { None => { - seen.insert(node, conns[ci].0.as_str()); + seen.insert(node, (conns[ci].0.as_str(), ci)); } - Some(&other) if other != conns[ci].0 => { + Some(&(_, cj)) if cj == ci => {} + Some(&(other, _)) if other != conns[ci].0 => { return CompleteOutcome::ProvedInfeasible { reason: format!( "terminals of nets {} and {other} collide in the same \ @@ -244,6 +489,32 @@ pub fn route_window_complete( } } } + // An escape barrel occupies its cell on every layer it spans, so those + // nodes are no longer free for anyone. Terminal nodes are exempt: they are + // already reserved one-per-connection by node disjointness, and blanking + // one here would make its own connection unroutable. + { + let terminal: HashSet = terms.iter().flat_map(|&(s, t)| [s, t]).collect(); + for (ci, &(se, te)) in escapes.iter().enumerate() { + for (escape, node) in [(se, terms[ci].0), (te, terms[ci].1)] { + let Some(pad_layer) = escape else { continue }; + let (cell, attach_li) = (node % plane, node / plane); + let pad_li = layers.iter().position(|l| *l == pad_layer).unwrap_or(0); + let span = if attach_li < pad_li { + attach_li..=pad_li + } else { + pad_li..=attach_li + }; + for li in span { + let barrel_node = li * plane + cell; + if !terminal.contains(&barrel_node) { + free[barrel_node] = false; + } + } + } + } + } + let free = free; // --- Edge legality (lazy, memoized) ---------------------------------- let mut edge_memo: HashMap = HashMap::new(); @@ -278,10 +549,11 @@ pub fn route_window_complete( center: grid.world(ca), r: via_r, }; - conns.iter().zip(&clearances).all(|((net, _, _), &clr)| { - session.probe(&disc, layers[la], net, clr).legal - && session.probe(&disc, layers[lb], net, clr).legal - }) + barrel_ok(grid.world(ca)) + && conns.iter().zip(&clearances).all(|((net, _, _), &clr)| { + session.probe(&disc, layers[la], net, clr).legal + && session.probe(&disc, layers[lb], net, clr).legal + }) }; edge_memo.insert(key, ok); ok @@ -313,6 +585,58 @@ pub fn route_window_complete( out }; + // --- One connection: reachability decides it exactly ------------------ + // With k = 1 there is nothing to be node-disjoint *from*, so "a joint + // routing exists" collapses to s–t reachability over the free graph. BFS + // settles that in O(E): either a shortest path (fewest cells, hence least + // copper) or an exhausted reachable component, which *is* the proof. No + // budget can trip, so a lone connection never comes back unknown — the + // exhaustive DFS is only ever needed to decide genuinely joint instances. + if conns.len() == 1 { + let (s, t) = terms[0]; + let empty = BitSet::new(n_nodes); + return match bfs_path(s, t, &free, &empty, &neighbors, &mut edge_ok) { + Ok(path) => CompleteOutcome::Routed(vec![emit_segments( + &grid, plane, layers, conns[0].1, conns[0].2, escapes[0], &path, + )]), + Err(reached_from) => { + // Report the tighter pocket. The graph is undirected, so either + // terminal's reachable component certifies the severance; the + // one enclosed by fewer nodes names a small, checkable cut + // instead of "everything but this corner". + let reached_to = bfs_path(t, usize::MAX, &free, &empty, &neighbors, &mut edge_ok) + .err() + .unwrap_or_default(); + let live = |r: &Vec| r.iter().filter(|v| **v).count(); + let (reached, from_side) = + if live(&reached_to) > 0 && live(&reached_to) < live(&reached_from) { + (reached_to, false) + } else { + (reached_from, true) + }; + CompleteOutcome::ProvedInfeasible { + reason: severed_reason( + session, + &grid, + &Topology { + plane, + layers, + via_r, + half_w, + }, + &conns[0], + clearances[0], + &free, + &reached, + &neighbors, + terms[0], + from_side, + ), + } + } + }; + } + // --- Max-flow necessary-condition pre-pass --------------------------- let (flow, cut_cells) = max_node_disjoint_flow(&free, plane, grid.nx, nl, &terms, &neighbors); if flow < conns.len() { @@ -347,10 +671,85 @@ pub fn route_window_complete( lo.y - grid.pitch / 2.0, hi.y + grid.pitch / 2.0, )); + } else { + // Flow 0 with an empty min *vertex* cut means the terminals are + // outright severed: nothing is saturated, so the cut is the ring of + // blocked nodes enclosing the sources. Name it — a certificate that + // does not say where the wall is, or whose copper it is, is not + // much of a certificate. + let mut reached = vec![false; n_nodes]; + for &(s, _) in &terms { + if !free[s] || reached[s] { + continue; + } + if let Err(seen) = bfs_path( + s, + usize::MAX, + &free, + &BitSet::new(n_nodes), + &neighbors, + &mut edge_ok, + ) { + for (v, r) in seen.iter().enumerate() { + reached[v] |= *r; + } + } + } + reason.push_str(&format!( + " ({})", + frontier_census( + session, + &grid, + &Topology { + plane, + layers, + via_r, + half_w, + }, + &conns[0].0, + clearances[0], + &free, + &reached, + &neighbors, + ) + )); } return CompleteOutcome::ProvedInfeasible { reason }; } + // --- Cheap witness attempt: sequential shortest paths ------------------ + // The DFS's first descent is a greedy *walk*, not a shortest path, so on a + // wide window it can wander for millions of expansions before its first + // completion — the dominant source of honest-but-avoidable unknowns. A few + // sequential BFS assignments (each net taking a shortest path through what + // the earlier nets left free) find the easy joint routings in milliseconds. + // Success is a witness: the paths are node-disjoint by construction, so it + // needs no proof. Failure proves nothing and falls through to the + // exhaustive search, so completeness is untouched. + for order in attempt_orders(&terms, grid.nx, plane) { + if let Some(paths) = + sequential_bfs(&order, &terms, &free, n_nodes, &neighbors, &mut edge_ok) + { + return CompleteOutcome::Routed( + paths + .iter() + .enumerate() + .map(|(ci, path)| { + emit_segments( + &grid, + plane, + layers, + conns[ci].1, + conns[ci].2, + escapes[ci], + path, + ) + }) + .collect(), + ); + } + } + // --- Exhaustive backtracking DFS ------------------------------------- let mut search = Search { free: &free, @@ -376,6 +775,7 @@ pub fn route_window_complete( layers, conns[ci].1, conns[ci].2, + escapes[ci], path, )); } @@ -521,13 +921,24 @@ fn emit_segments( layers: &[PcbLayer], from: Vec2, to: Vec2, + escape: (Option, Option), path: &[usize], ) -> Vec<(Vec2, Vec2, PcbLayer)> { let mut segs: Vec<(Vec2, Vec2, PcbLayer)> = Vec::new(); if path.is_empty() { return segs; } - let mut run: Vec = vec![from]; + // A dog-bone terminal contributes its stub on the pad's own layer; the + // layer change at the stub's far end is a via the caller materializes from + // the layer discontinuity, exactly as for a mid-path layer change. + let first_cell = grid.world(path[0] % plane); + let mut run: Vec = match escape.0 { + Some(pad_layer) => { + segs.push((from, first_cell, pad_layer)); + vec![first_cell] + } + None => vec![from], + }; let mut run_layer = path[0] / plane; let flush = |run: &mut Vec, layer: PcbLayer, segs: &mut Vec<(Vec2, Vec2, PcbLayer)>| { let pts = simplify(run); @@ -555,13 +966,302 @@ fn emit_segments( run.push(w); } } - if run.last().map(|p| dist(*p, to) > 1e-9).unwrap_or(true) { - run.push(to); + match escape.1 { + Some(pad_layer) => { + let last_cell = grid.world(path[path.len() - 1] % plane); + flush(&mut run, layers[run_layer], &mut segs); + segs.push((last_cell, to, pad_layer)); + } + None => { + if run.last().map(|p| dist(*p, to) > 1e-9).unwrap_or(true) { + run.push(to); + } + flush(&mut run, layers[run_layer], &mut segs); + } } - flush(&mut run, layers[run_layer], &mut segs); segs } +/// The window discretization facts the path and certificate helpers share. +struct Topology<'a> { + plane: usize, + layers: &'a [PcbLayer], + via_r: f64, + half_w: f64, +} + +/// Shortest path (fewest nodes) from `start` to `goal` over free, unoccupied +/// nodes joined by legal edges. +/// +/// On failure the reachable set is returned — the exhausted component that *is* +/// the infeasibility proof for a single connection. Passing `usize::MAX` as +/// `goal` asks for that set outright. +fn bfs_path( + start: usize, + goal: usize, + free: &[bool], + occ: &BitSet, + neighbors: &dyn Fn(usize) -> Vec, + edge_ok: &mut dyn FnMut(usize, usize) -> bool, +) -> Result, Vec> { + let mut seen = vec![false; free.len()]; + if !free[start] || occ.get(start) { + return Err(seen); + } + let mut prev = vec![usize::MAX; free.len()]; + seen[start] = true; + let mut q = VecDeque::from([start]); + while let Some(u) = q.pop_front() { + if u == goal { + let mut path = vec![u]; + let mut cur = u; + while cur != start { + cur = prev[cur]; + path.push(cur); + } + path.reverse(); + return Ok(path); + } + for nb in neighbors(u) { + if seen[nb] || !free[nb] || occ.get(nb) || !edge_ok(u, nb) { + continue; + } + seen[nb] = true; + prev[nb] = u; + q.push_back(nb); + } + } + Err(seen) +} + +/// Route the connections in `order`, each taking a shortest path through the +/// nodes its predecessors left free. Returns per-connection paths (indexed by +/// connection, not by position in `order`) when every one succeeds — a +/// node-disjoint joint routing by construction. `None` means only "this order +/// did not work"; it is never evidence of infeasibility. +fn sequential_bfs( + order: &[usize], + terms: &[(usize, usize)], + free: &[bool], + n_nodes: usize, + neighbors: &dyn Fn(usize) -> Vec, + edge_ok: &mut dyn FnMut(usize, usize) -> bool, +) -> Option>> { + let mut occ = BitSet::new(n_nodes); + let mut paths = vec![Vec::new(); terms.len()]; + for &i in order { + let (s, t) = terms[i]; + if occ.get(t) { + return None; + } + let path = bfs_path(s, t, free, &occ, neighbors, edge_ok).ok()?; + for &node in &path { + occ.set(node); + } + paths[i] = path; + } + Some(paths) +} + +/// Deterministic connection orders for the witness attempt: as given, longest +/// terminal span first (the constrained nets pick their corridor before the +/// short ones fill it), then shortest first. +fn attempt_orders(terms: &[(usize, usize)], nx: usize, plane: usize) -> Vec> { + let span = |&(s, t): &(usize, usize)| { + let coords = |n: usize| ((n % plane) % nx, (n % plane) / nx, n / plane); + let (sx, sy, sl) = coords(s); + let (tx, ty, tl) = coords(t); + sx.abs_diff(tx) + sy.abs_diff(ty) + sl.abs_diff(tl) + }; + let identity: Vec = (0..terms.len()).collect(); + let mut longest = identity.clone(); + longest.sort_by_key(|&i| (std::cmp::Reverse(span(&terms[i])), i)); + let mut shortest = identity.clone(); + shortest.sort_by_key(|&i| (span(&terms[i]), i)); + let mut orders = vec![identity]; + for order in [longest, shortest] { + if !orders.contains(&order) { + orders.push(order); + } + } + orders +} + +/// Tally, per net, how many blockers stand between `geom` and legality on any +/// of `layers`, for any of the candidate `nets` (net name, clearance). +fn census_add( + census: &mut BTreeMap, + session: &RouteSession, + geom: &CopperGeom, + layers: &[PcbLayer], + nets: &[(&str, f64)], +) { + for &layer in layers { + for &(net, clearance) in nets { + for blocker in session.probe(geom, layer, net, clearance).blockers { + *census.entry(blocker.net).or_default() += 1; + } + } + } +} + +/// Nets whose copper blocks `geom`, as a census. +fn blocking_nets( + session: &RouteSession, + geom: &CopperGeom, + layers: &[PcbLayer], + net: &str, + clearance: f64, +) -> BTreeMap { + let mut census = BTreeMap::new(); + census_add(&mut census, session, geom, layers, &[(net, clearance)]); + census +} + +/// The heaviest few blockers, named: `"GND (61), /VDD_5V (4)"`. +fn name_census(census: &BTreeMap) -> String { + if census.is_empty() { + return "copper the window's union-legality raster rejects for a sibling net".into(); + } + let mut ranked: Vec<(&String, &usize)> = census.iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + ranked + .iter() + .take(3) + .map(|(net, n)| { + let named = if net.is_empty() { "" } else { net }; + format!("{named} ({n})") + }) + .collect::>() + .join(", ") +} + +/// Describe the ring of blocked nodes that encloses `reached` — the vertex cut +/// the search ran into — by size, extent, and the copper that forms it. +#[allow(clippy::too_many_arguments)] +fn frontier_census( + session: &RouteSession, + grid: &WinGrid, + topo: &Topology, + net: &str, + clearance: f64, + free: &[bool], + reached: &[bool], + neighbors: &dyn Fn(usize) -> Vec, +) -> String { + let mut cut: Vec = Vec::new(); + let mut census: BTreeMap = BTreeMap::new(); + let (mut lo, mut hi) = ( + Vec2::new(f64::INFINITY, f64::INFINITY), + Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), + ); + for (v, _) in reached.iter().enumerate().filter(|(_, r)| **r) { + for nb in neighbors(v) { + if reached[nb] { + continue; + } + cut.push(nb); + let p = grid.world(nb % topo.plane); + lo.x = lo.x.min(p.x); + lo.y = lo.y.min(p.y); + hi.x = hi.x.max(p.x); + hi.y = hi.y.max(p.y); + // The copper that forbids this step: the swept trace for an + // in-plane move, the via pad for a layer change. + let (la, ca) = (v / topo.plane, v % topo.plane); + let (lb, cb) = (nb / topo.plane, nb % topo.plane); + if la == lb { + let geom = CopperGeom::Segment { + a: grid.world(ca), + b: grid.world(cb), + half_w: topo.half_w, + }; + census_add( + &mut census, + session, + &geom, + &[topo.layers[la]], + &[(net, clearance)], + ); + } else { + let geom = CopperGeom::Disc { + center: grid.world(ca), + r: topo.via_r, + }; + census_add( + &mut census, + session, + &geom, + &[topo.layers[la], topo.layers[lb]], + &[(net, clearance)], + ); + } + } + } + cut.sort_unstable(); + cut.dedup(); + if cut.is_empty() { + return format!( + "the {} free node{} it can reach have no blocked neighbour at all: the window \ + itself is too small to leave the terminal's pocket", + free.iter().filter(|f| **f).count(), + if free.len() == 1 { "" } else { "s" } + ); + } + format!( + "the enclosing cut is {} blocked node{} spanning x={:.2}..{:.2}, y={:.2}..{:.2}, held \ + by {}", + cut.len(), + if cut.len() == 1 { "" } else { "s" }, + lo.x - grid.pitch / 2.0, + hi.x + grid.pitch / 2.0, + lo.y - grid.pitch / 2.0, + hi.y + grid.pitch / 2.0, + name_census(&census), + ) +} + +/// Certificate for a lone connection whose terminals are severed on the +/// canonical grid: what was exhausted, and which copper closed the door. +#[allow(clippy::too_many_arguments)] +fn severed_reason( + session: &RouteSession, + grid: &WinGrid, + topo: &Topology, + conn: &(String, Vec2, Vec2), + clearance: f64, + free: &[bool], + reached: &[bool], + neighbors: &dyn Fn(usize) -> Vec, + terms: (usize, usize), + from_side: bool, +) -> String { + let (net, from, to) = conn; + let n_free = free.iter().filter(|f| **f).count(); + let n_reached = reached.iter().filter(|r| **r).count(); + let (searched, other, other_node) = if from_side { + (from, to, terms.1) + } else { + (to, from, terms.0) + }; + format!( + "net {net} is severed inside the window: breadth-first search from its \ + {} terminal at ({:.2}, {:.2}) exhausted every reachable node — {n_reached} of the \ + {n_free} clearance-free (cell, layer) nodes on the {}-layer stack — without touching \ + the other terminal at ({:.2}, {:.2}) (layer {:?}); {}. With k = 1 reachability is \ + exact, so no path exists on the canonical grid at pitch {:.3} mm", + if from_side { "from" } else { "to" }, + searched.x, + searched.y, + topo.layers.len(), + other.x, + other.y, + topo.layers[other_node / topo.plane], + frontier_census(session, grid, topo, net, clearance, free, reached, neighbors), + grid.pitch, + ) +} + /// Max number of pairwise node-disjoint source→target paths through the free /// nodes (unit node capacities via node splitting), together with the cells /// of the min vertex cut when the flow is short. Pairing-agnostic: any source @@ -732,17 +1432,26 @@ struct WinGrid { } impl WinGrid { - fn new(window: (Vec2, Vec2), width: f64, clearance: f64) -> Self { + fn new( + window: (Vec2, Vec2), + width: f64, + separation: f64, + drill_floor: f64, + max_axis_cells: usize, + ) -> Self { let (lo, hi) = window; let span_x = (hi.x - lo.x).max(1e-3); let span_y = (hi.y - lo.y).max(1e-3); - // 6% over the exact width+clearance floor: node-disjoint paths in - // adjacent columns sit at pitch - width — exactly the clearance at + // 6% over the exact width+separation floor: node-disjoint paths in + // adjacent columns sit at pitch - width — exactly the separation at // the floor, which board DRC then fails on floating-point margins. - let mut pitch = ((width + clearance) * 1.06).max(0.02); + let mut pitch = ((width + separation) * 1.06) + .max(0.02) + .max(drill_floor * 1.02); + let cap = max_axis_cells.max(2) as f64; let need = (span_x / pitch).max(span_y / pitch); - if need > MAX_AXIS_CELLS as f64 { - pitch = (span_x / MAX_AXIS_CELLS as f64).max(span_y / MAX_AXIS_CELLS as f64); + if need > cap { + pitch = (span_x / cap).max(span_y / cap); } let nx = (span_x / pitch).ceil() as usize + 1; let ny = (span_y / pitch).ceil() as usize + 1; @@ -1030,11 +1739,101 @@ mod tests { assert_probe_legal(&session, &conns, &routed, 0.25); } + /// A lone connection is decided by reachability, which no budget can + /// interrupt: budget 1 must still answer, and answer definitively. + #[test] + fn one_connection_is_never_unknown() { + let pcb = board(vec![], &[PcbLayer::FCu, PcbLayer::BCu]); + let session = RouteSession::from_pcb(&pcb); + let conns = vec![( + "A".to_string(), + Vec2::new(12.0, 12.0), + Vec2::new(28.0, 28.0), + )]; + let r = route_window_complete(&session, WINDOW, &[PcbLayer::FCu], &conns, 0.25, 1); + let CompleteOutcome::Routed(routed) = r else { + panic!("a reachable lone connection must route at any budget, got {r:?}"); + }; + assert_connected(&conns, &routed); + assert_probe_legal(&session, &conns, &routed, 0.25); + } + + /// A lone connection walled off by foreign copper is *proved* infeasible — + /// never unknown — and the certificate names the cut it ran into. + #[test] + fn one_severed_connection_is_proved_with_a_named_cut() { + // A closed box of foreign copper around the source terminal. + let boxed = vec![ + trace("GND", Vec2::new(11.0, 11.0), Vec2::new(13.0, 11.0)), + trace("GND", Vec2::new(13.0, 11.0), Vec2::new(13.0, 13.0)), + trace("GND", Vec2::new(13.0, 13.0), Vec2::new(11.0, 13.0)), + trace("GND", Vec2::new(11.0, 13.0), Vec2::new(11.0, 11.0)), + ]; + let pcb = board(boxed, &[PcbLayer::FCu]); + let session = RouteSession::from_pcb(&pcb); + let conns = vec![( + "A".to_string(), + Vec2::new(12.0, 12.0), + Vec2::new(28.0, 28.0), + )]; + let r = route_window_complete(&session, WINDOW, &[PcbLayer::FCu], &conns, 0.25, 2_000_000); + let CompleteOutcome::ProvedInfeasible { reason } = r else { + panic!("a boxed-in terminal must be proved infeasible, got {r:?}"); + }; + assert!( + reason.contains("GND"), + "certificate must name the copper forming the cut: {reason}" + ); + } + + /// Terminals pinned to a layer must attach there — a path that surfaces on + /// another layer would be electrically dangling. #[test] - fn tiny_budget_reports_unknown_never_infeasible() { - // Same (feasible) instance as the crossing test: with budget=1 the - // flow pre-pass passes, the DFS trips immediately, and the honest - // answer is BudgetExhausted — never a fake infeasibility proof. + fn pinned_terminals_attach_on_their_own_layer() { + let pcb = board(vec![], &[PcbLayer::FCu, PcbLayer::BCu]); + let session = RouteSession::from_pcb(&pcb); + let conns = vec![( + "A".to_string(), + Vec2::new(12.0, 12.0), + Vec2::new(28.0, 28.0), + )]; + let pins = vec![TerminalLayers { + from: vec![PcbLayer::BCu], + to: vec![PcbLayer::BCu], + }]; + let r = route_window_complete_pinned( + &session, + WINDOW, + &[PcbLayer::FCu, PcbLayer::BCu], + &conns, + &pins, + 0.25, + None, + WindowBudget::new(2_000_000), + ); + let CompleteOutcome::Routed(routed) = r else { + panic!("an empty board must route a pinned connection, got {r:?}"); + }; + assert_connected(&conns, &routed); + assert_eq!( + routed[0].first().map(|s| s.2), + Some(PcbLayer::BCu), + "the first segment must leave the pad on the pinned layer" + ); + assert_eq!( + routed[0].last().map(|s| s.2), + Some(PcbLayer::BCu), + "the last segment must reach the pad on the pinned layer" + ); + } + + #[test] + fn tiny_budget_never_fakes_infeasibility() { + // Same (feasible) instance as the crossing test, with budget=1. The + // budget bounds the exhaustive DFS only: the sequential-BFS witness + // pass runs first and settles this instance, so the outcome here is a + // routing. What must never happen — at any budget — is a claim of + // infeasibility for an instance whose space was not exhausted. let pcb = board(vec![], &[PcbLayer::FCu, PcbLayer::BCu]); let session = RouteSession::from_pcb(&pcb); let conns = vec![ @@ -1063,8 +1862,13 @@ mod tests { 1, ); assert!( - matches!(r, CompleteOutcome::BudgetExhausted), - "budget=1 must yield BudgetExhausted, got {r:?}" + !matches!(r, CompleteOutcome::ProvedInfeasible { .. }), + "budget=1 must never yield an infeasibility proof, got {r:?}" ); + let CompleteOutcome::Routed(routed) = r else { + panic!("the witness pass settles this feasible instance, got {r:?}"); + }; + assert_connected(&conns, &routed); + assert_probe_legal(&session, &conns, &routed, 0.25); } } diff --git a/crates/vcad-ecad-pcb/src/session.rs b/crates/vcad-ecad-pcb/src/session.rs index 099cc28cd..af90d6eff 100644 --- a/crates/vcad-ecad-pcb/src/session.rs +++ b/crates/vcad-ecad-pcb/src/session.rs @@ -60,6 +60,40 @@ impl RTreeObject for SessionElement { } } +/// A drilled hole (via barrel or through-hole pad) in the session's drill +/// index. Holes are indexed apart from copper because the hole-to-hole rule is +/// mechanical: it ignores nets *and* layers, so two vias whose layer spans never +/// meet — a blind In1–In2 microvia and a buried In5–In6 one — are invisible to a +/// layer-scoped copper probe yet still collide in the drill file. +#[derive(Clone)] +struct DrillElement { + center: Vec2, + radius: f64, +} + +impl RTreeObject for DrillElement { + 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], + ) + } +} + +/// Result of a [`RouteSession::probe_drill`] hole-to-hole test. +#[derive(Debug, Clone, PartialEq)] +pub struct DrillProbe { + /// Whether the candidate hole satisfies the board's hole-to-hole rule. + pub legal: bool, + /// Closest edge-to-edge distance to an existing hole (mm); infinite when + /// the board has no other holes. + pub min_spacing: f64, + /// Centre of the nearest hole, when one was found. + pub nearest: Option, +} + /// A piece of existing copper found within the required clearance of a probed /// candidate. #[derive(Debug, Clone, PartialEq)] @@ -161,11 +195,25 @@ pub struct RouteSession { /// probe enforces the pair GAP between the twins' leg-width copper, so no /// routing stage can emit an intra-pair pinch the DRC would flag. pair_rules: HashMap, + /// Pad positions of the nets in `pair_rules` — the breakout regions where + /// the DRC relaxes the pair gap (see [`RouteSession::in_pair_breakout`]). + pair_pads: HashMap>, + /// Whether the intra-pair gap is scored the way the DRC scores it, including + /// against copper that carries no width of its own — see + /// [`RouteSession::set_strict_pair_coupling`]. + strict_pair_coupling: bool, default_clearance: f64, /// Largest clearance any net requires — the broadphase must reach this far /// so a wide net's clearance is never missed when it exceeds the candidate's. max_clearance: f64, net_width: HashMap, + /// Every drilled hole on the board, for the layer- and net-agnostic + /// hole-to-hole rule (see [`DrillElement`]). + drills: RTree, + /// Largest indexed drill radius — the broadphase reach for a drill probe. + max_drill_radius: f64, + /// The board's minimum hole-to-hole edge spacing (mm). + hole_to_hole: f64, /// Per-span bboxes (parallel to `live`), so a remove can stamp the dirty /// grid without consulting the tree. bounds: Vec<[f64; 4]>, @@ -272,6 +320,24 @@ impl RouteSession { .values() .copied() .fold(default_clearance, f64::max); + let drills = drill_elements(pcb); + let max_drill_radius = drills.iter().map(|d| d.radius).fold(0.0_f64, f64::max); + let pair_nets: std::collections::HashSet = crate::drc::diff_pairs(pcb) + .into_iter() + .flat_map(|dp| [dp.net_p, dp.net_n]) + .collect(); + let mut pair_pads: HashMap> = HashMap::new(); + for fp in &pcb.footprints { + for pad in &fp.pads { + let Some(net) = pad.net.as_ref().filter(|n| pair_nets.contains(*n)) else { + continue; + }; + pair_pads + .entry(net.clone()) + .or_default() + .push(crate::geometry::pad_world_position(fp, pad)); + } + } Self { tree: RTree::bulk_load(session_elems), live, @@ -286,9 +352,14 @@ impl RouteSession { } m }, + pair_pads, + strict_pair_coupling: false, default_clearance, max_clearance, net_width: build_net_trace_width_map(pcb), + drills: RTree::bulk_load(drills), + max_drill_radius, + hole_to_hole: pcb.rules.hole_to_hole, bounds, dirty, change_epoch: 0, @@ -316,6 +387,113 @@ impl RouteSession { .unwrap_or(self.default_clearance) } + /// Test a candidate drilled hole against every hole already on the board. + /// + /// Nets and layers are deliberately ignored: hole-to-hole is a mechanical + /// rule about the drill file, and it is the one legality question a copper + /// probe structurally cannot answer — two vias on disjoint layer spans share + /// no layer, so no layer-scoped probe ever compares them, and they land as + /// drill collisions the DRC only finds after the fact. + pub fn probe_drill(&self, center: Vec2, diameter: f64) -> DrillProbe { + let r = diameter / 2.0; + let reach = r + self.max_drill_radius + self.hole_to_hole; + let mut min_spacing = f64::INFINITY; + let mut nearest = None; + for hole in self + .drills + .locate_in_envelope_intersecting(&AABB::from_corners( + [center.x - reach, center.y - reach], + [center.x + reach, center.y + reach], + )) + { + let d = ((hole.center.x - center.x).powi(2) + (hole.center.y - center.y).powi(2)) + .sqrt() + - hole.radius + - r; + if d < min_spacing { + min_spacing = d; + nearest = Some(hole.center); + } + } + DrillProbe { + legal: min_spacing >= self.hole_to_hole - 1e-6, + min_spacing, + nearest, + } + } + + /// Score the intra-pair gap exactly as the DRC does — including against a + /// twin's pads and via annuli, which carry no width of their own and which + /// `drc::pair_aware_clearance_w` therefore treats as coupled leg copper. + /// + /// Off by default, because it is *stricter* than the geometry the pair + /// router currently realizes: its dog-bone offsets and jogs are sized to the + /// base clearance, so turning this on globally would stop pairs from + /// changing layers rather than making them legal (see the twin-clearance + /// tests in `router::pair`). A caller that must not emit copper the board + /// DRC will flag — the verdict driver, which commits fail-closed — turns it + /// on and pays for it in routability instead. + pub fn set_strict_pair_coupling(&mut self, strict: bool) { + self.strict_pair_coupling = strict; + } + + /// Whether a candidate/blocker pair sits in a twin's **pad breakout**, where + /// the DRC relaxes the intra-pair gap to the base clearance. + /// + /// Mirrors `drc::check_clearance`: the gap binds the coupled run, not the + /// escape region (1.5 mm) around either twin's own pads, where the legs must + /// converge to reach their land patterns. The DRC judges a pair from the + /// *trace* side, so copper with no endpoints of its own — a via annulus or a + /// pad — imposes nothing from its side of the comparison. + fn in_pair_breakout( + &self, + cand: &CopperGeom, + blocker: &CopperGeom, + net: &str, + twin: &str, + ) -> bool { + const BREAKOUT_MM: f64 = 1.5; + let near_pad = + |p: Vec2| { + [net, twin].iter().any(|n| { + self.pair_pads.get(*n).into_iter().flatten().any(|q| { + (q.x - p.x).powi(2) + (q.y - p.y).powi(2) <= BREAKOUT_MM * BREAKOUT_MM + }) + }) + }; + let side = |g: &CopperGeom| match g { + CopperGeom::Segment { a, b, .. } => near_pad(*a) || near_pad(*b), + _ => true, + }; + side(cand) && side(blocker) + } + + /// The board's minimum hole-to-hole edge spacing (mm). + pub fn hole_to_hole(&self) -> f64 { + self.hole_to_hole + } + + /// Index a drilled hole so later [`RouteSession::probe_drill`] calls see it. + /// Call this for every via a router commits — the copper commit alone leaves + /// the barrel invisible to the hole-to-hole rule. + pub fn commit_drill(&mut self, center: Vec2, diameter: f64) { + let radius = diameter / 2.0; + self.max_drill_radius = self.max_drill_radius.max(radius); + self.drills.insert(DrillElement { center, radius }); + } + + /// The declared intra-pair gap between `net` and `other` when the two are + /// twins of a differential pair, else `None`. This is the separation the + /// probe demands between their leg-width copper, so a router that must keep + /// two candidate paths mutually legal has to space them by at least this + /// much — not merely by the base clearance. + pub fn pair_gap_between(&self, net: &str, other: &str) -> Option { + self.pair_rules + .get(net) + .filter(|(twin, _, _)| twin == other) + .map(|&(_, gap, _)| gap) + } + /// The trace width for `net` from its net class, or `fallback` if the net /// has no class width (so power/ground classes route wider than signals). pub fn width_for(&self, net: &str, fallback: f64) -> f64 { @@ -532,11 +710,18 @@ impl RouteSession { // base clearance — the uncoupled entry region. if let Some((twin, gap, leg_w)) = self.pair_rules.get(net) { if &e.net == twin { - let fat = |g: &CopperGeom| match g { + // Which copper counts as a coupled leg. Segments count at + // (nearly) the leg width — a thinner neck is the uncoupled + // entry by definition. Copper with no width of its own (a + // pad, a via annulus) counts only in strict mode, which is + // how `drc::pair_aware_clearance_w` scores it. + let strict = self.strict_pair_coupling; + let leg = |g: &CopperGeom| match g { CopperGeom::Segment { half_w, .. } => 2.0 * half_w >= leg_w - 0.01, - _ => false, + _ => strict, }; - if fat(geom) && fat(&e.geom) { + if leg(geom) && leg(&e.geom) && !self.in_pair_breakout(geom, &e.geom, net, twin) + { required = required.max(gap - 0.005); } } @@ -559,6 +744,31 @@ impl RouteSession { } } +/// Every drilled hole on `pcb`: via barrels and through-hole pads. Mirrors the +/// DRC's own hole census (`check_hole_to_hole`), so the router's answer and the +/// board's verdict cannot disagree. +fn drill_elements(pcb: &Pcb) -> Vec { + let mut out: Vec = pcb + .vias + .iter() + .map(|via| DrillElement { + center: via.position, + radius: via.drill / 2.0, + }) + .collect(); + for fp in &pcb.footprints { + for pad in &fp.pads { + if let Some(drill) = &pad.drill { + out.push(DrillElement { + center: crate::geometry::pad_world_position(fp, pad), + radius: drill.diameter / 2.0, + }); + } + } + } + out +} + /// Axis-aligned bounding box (copper extent) of a [`CopperGeom`]. fn geom_aabb(g: &CopperGeom) -> ([f64; 2], [f64; 2]) { match g { @@ -824,6 +1034,56 @@ mod tests { assert!(r.legal, "net-tied copper must be exempt"); } + /// Drills are compared across layer spans and nets, because that is what + /// the fab file says: two vias whose copper never shares a layer still + /// collide in the drill. This is the one legality question the copper probe + /// structurally cannot answer. + #[test] + fn drill_probe_sees_holes_no_copper_probe_could() { + let mut pcb = empty_pcb(); + pcb.vias.push(vcad_ir::ecad::Via { + position: Vec2::new(50.0, 50.0), + diameter: 0.8, + drill: 0.4, + start_layer: PcbLayer::In1Cu, + end_layer: PcbLayer::In2Cu, + net: "GND".into(), + source: None, + }); + let mut session = RouteSession::from_pcb(&pcb); + // hole_to_hole is 0.5 here: edge distance must be at least that. + let touching = session.probe_drill(Vec2::new(50.4, 50.0), 0.4); + assert!( + !touching.legal && touching.min_spacing < 0.5, + "a hole 0.4mm away (edge 0.0mm) must be illegal, got {touching:?}" + ); + assert_eq!(touching.nearest, Some(Vec2::new(50.0, 50.0))); + let clear = session.probe_drill(Vec2::new(51.5, 50.0), 0.4); + assert!( + clear.legal, + "a hole 1.5mm away must be legal, got {clear:?}" + ); + // A copper probe on a layer the existing via does not span sees nothing + // there at all — hence the separate index. + assert!( + session + .probe( + &CopperGeom::Disc { + center: Vec2::new(50.4, 50.0), + r: 0.4, + }, + PcbLayer::In5Cu, + "SIG", + 0.2, + ) + .legal, + "the copper probe cannot see a hole on an unshared layer" + ); + // Newly committed drills join the index. + session.commit_drill(Vec2::new(60.0, 60.0), 0.4); + assert!(!session.probe_drill(Vec2::new(60.3, 60.0), 0.4).legal); + } + #[test] fn ids_stay_stable_across_compaction() { let mut session = RouteSession::from_pcb(&empty_pcb());