main
Caleb Publish 0.2.0 cbe04a0 11h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
//! The designer's routing standards as numbers (PCB_Design_Process.md Part 2.2, 4.2, 4.4). These are the
//! defaults; a spec file may override them, with the same key names the reference board specs already use.

use crate::board::{Board, NetId};
use crate::geom::*;
use serde_json::Value;
use std::collections::{HashMap, HashSet};

#[derive(Clone, Debug)]
pub struct Pair {
    pub p: String,
    pub n: String,
    pub width: i64,
    pub gap: i64,
}

/// One net class as written in the project. A field left out inherits from the Default class.
#[derive(Clone, Debug, Default)]
pub struct NetClass {
    pub name: String,
    /// Lower number wins when a net matches several classes; Default is always last.
    pub priority: i64,
    pub clearance: Option<i64>,
    pub track: Option<i64>,
    pub via_dia: Option<i64>,
    pub via_drill: Option<i64>,
    pub pair_width: Option<i64>,
    pub pair_gap: Option<i64>,
}

/// A net's effective class after merging, every field filled.
#[derive(Clone, Debug)]
pub struct Resolved {
    pub name: String,
    pub clearance: i64,
    pub track: i64,
    pub via_dia: i64,
    pub via_drill: i64,
    pub pair_width: i64,
    pub pair_gap: i64,
}

/// Project-wide minimums (.kicad_pro board/design_settings/rules and .kicad_dru). Zero: not set.
#[derive(Clone, Debug, Default)]
pub struct Minimums {
    pub clearance: i64,
    pub track: i64,
    pub hole_clearance: i64,
    pub hole_to_hole: i64,
    pub via_dia: i64,
    pub through_drill: i64,
    pub annular: i64,
    /// `solder_mask_to_copper_clearance`: what KiCad adds to a pad's mask margin when it tests
    /// copper of another net against that opening.
    pub mask_to_copper: i64,
}

/// Settings the user typed on the command line. The project file governs otherwise, and a spec's
/// numbers can only raise it: a spec is often an old run's, and must not lower his rule unasked. A
/// named setting wins, and the output board's project is updated to match, so DRC and the route agree
/// (reference rule: "if the user initially specifies a default the settings should be updated to match").
#[derive(Clone, Copy, Debug, Default)]
pub struct Explicit {
    pub clearance: Option<i64>,
    pub track: Option<i64>,
    pub neck: Option<i64>,
    pub via: Option<i64>,
    pub drill: Option<i64>,
}

impl Explicit {
    pub fn any(&self) -> bool {
        self.clearance.is_some() || self.track.is_some() || self.neck.is_some() || self.via.is_some() || self.drill.is_some()
    }
}

/// A clearance lowered for one net at one pad, in a .kicad_dru: the recorded exception for a pad no
/// track can leave under its class (reference decision on benchmark B U3.12: a local rule at the pad,
/// for review, rather than a lower class or a broken one).
#[derive(Clone, Debug, PartialEq)]
pub struct PadRule {
    pub net: String,
    pub reference: String,
    pub pad: String,
    pub min: i64,
}

impl PadRule {
    /// The condition KiCad's DRC honours. Measured on KiCad 10 on a copy of benchmark B: this form
    /// clears exactly the pad's two findings; `B.Parent.Reference` matches nothing and says nothing.
    pub fn condition(&self) -> String {
        format!("A.NetName == '{}' && B.memberOfFootprint('{}') && B.Pad_Number == '{}'", self.net, self.reference, self.pad)
    }

    /// Reads a condition `condition` wrote; any other condition is not one of these rules.
    pub fn parse(cond: &str, min: i64) -> Option<PadRule> {
        let rest = cond.strip_prefix("A.NetName == '")?;
        let (net, rest) = rest.split_once("' && B.memberOfFootprint('")?;
        let (reference, rest) = rest.split_once("') && B.Pad_Number == '")?;
        let pad = rest.strip_suffix('\'')?;
        Some(PadRule { net: net.to_string(), reference: reference.to_string(), pad: pad.to_string(), min })
    }
}

/// One rail's current, as approved by the designer: spec `rails`. Part references, and amps.
#[derive(Clone, Debug, Default)]
pub struct RailSpec {
    /// Where the current enters this net.
    pub sources: Vec<String>,
    /// The most the rail can carry: its worst-case sum, or what its source limits it to.
    pub amps: f64,
    /// What each part draws from this net. A part may list up to `amps`; a segment carries the sum
    /// of what lies past it, never more than `amps`.
    pub loads: std::collections::BTreeMap<String, f64>,
    /// The rail's voltage, for the drop limit. None: the spec doesn't say, and no drop is judged
    /// (a voltage is never guessed from a net's name here).
    pub volts: Option<f64>,
}

#[derive(Clone, Debug)]
pub struct Rules {
    pub explicit: Explicit,
    /// In name order, and each rail's loads too: a hash map's order changes on every start of the
    /// program, and sizing the rails in a different order gave different widths (clock.rs's test,
    /// Benchmark C, 2026-11-09).
    pub rails: std::collections::BTreeMap<String, RailSpec>,
    /// Board under 3 x 3 in, set by `bind`: where 4.2's 1.25 x margin and floor apply.
    pub small_board: bool,
    /// Outer copper thickness, um, set by `bind`.
    pub copper_um: f64,
    pub pad_rules: Vec<PadRule>,
    /// `pad_rules` on the bound board: (net, pad index) to clearance.
    pub pad_exit: HashMap<(NetId, u32), i64>,
    /// Minimum clearance on every net, fine-pitch escapes included (2.2).
    pub clearance: i64,
    /// Data line width on open runs, 0.2-0.3 mm (4.2).
    pub track: i64,
    /// Width leaving a fine-pitch part; necks up for long runs (4.2).
    pub neck: i64,
    pub via: i64,
    pub drill: i64,
    pub edge: i64,
    /// Hole to hole, any net (2.2).
    pub hole_hole: i64,
    /// A pad's own via sits this far clear of the pad (4.4).
    pub pad_via_gap: i64,
    /// How far around a fine-pitch part counts as its escape.
    pub escape_margin: f64,
    /// Main-run floor for power on boards under 3 x 3 in (4.2).
    pub power_floor: i64,
    /// Explicit power widths per net: main run, then branches (spec wideNets).
    pub wide: HashMap<String, Vec<i64>>,
    /// Spec `loads` in amps per net and `maxRiseC`: what 4.2 says to size a rail from. Reported beside
    /// the drawn widths; see `Router::rail_notes` for why they don't set a width yet.
    pub loads: HashMap<String, f64>,
    pub max_rise: Option<f64>,
    pub pairs: Vec<Pair>,
    pub kelvin: Vec<String>,
    pub priority: Vec<String>,
    pub plane_nets: Vec<String>,
    pub skip_refs: Vec<String>,
    /// Search cost weights in nm of equivalent track length. Bends are weighed against distance.
    pub cost_bend45: f64,
    pub cost_bend90: f64,
    pub cost_via: f64,
    pub cost_bottom: f64,
    pub cost_tight: f64,
    pub node_limit: usize,
    pub passes: usize,
    /// Whole-run budget in seconds; past it, the best result so far is returned (4.6).
    pub time_limit: f64,
    /// The standing minimum clearance before the project raises `clearance`: never go under it (2.2).
    pub floor: i64,
    pub classes: Vec<NetClass>,
    /// netclass_patterns: (wildcard, class name).
    pub patterns: Vec<(String, String)>,
    /// netclass_assignments: net name to class names.
    pub assignments: HashMap<String, Vec<String>>,
    pub min: Minimums,
    /// spec placement.innerFills: (footprint, net) that must be filled under the part (4.7).
    pub inner_fills: Vec<(String, String)>,
    /// rule_severities from the project (error, warning, ignore).
    pub severities: HashMap<String, String>,
    /// Filled by `bind`: effective classes, the class of each net id, and coupled pairs.
    resolved: Vec<Resolved>,
    net_class: Vec<u32>,
    coupled: HashSet<(NetId, NetId)>,
    max_clearance: i64,
    /// When the search must stop, so the time limit holds even inside one long search.
    pub deadline: Option<crate::clock::Instant>,
    /// A* heuristic inflation; a soft search may run greedier than a first attempt.
    pub search_weight: f64,
}

impl Default for Rules {
    fn default() -> Self {
        Rules {
            explicit: Explicit::default(),
            rails: Default::default(),
            small_board: false,
            copper_um: 35.0,
            pad_rules: Vec::new(),
            pad_exit: HashMap::new(),
            clearance: mm(0.15),
            track: mm(0.2),
            neck: mm(0.15),
            via: mm(0.6),
            drill: mm(0.3),
            edge: mm(0.3),
            hole_hole: mm(0.25),
            pad_via_gap: mm(0.1),
            escape_margin: 1.0 * NM,
            power_floor: mm(0.4),
            wide: HashMap::new(),
            loads: HashMap::new(),
            max_rise: None,
            pairs: Vec::new(),
            kelvin: Vec::new(),
            priority: Vec::new(),
            plane_nets: Vec::new(),
            skip_refs: Vec::new(),
            cost_bend45: 0.3 * NM,
            cost_bend90: 1.2 * NM,
            cost_via: 3.0 * NM,
            cost_bottom: 1.05,
            cost_tight: 1.5,
            node_limit: 800_000,
            passes: 6,
            time_limit: 240.0,
            floor: mm(0.15),
            classes: Vec::new(),
            patterns: Vec::new(),
            assignments: HashMap::new(),
            min: Minimums::default(),
            inner_fills: Vec::new(),
            severities: HashMap::new(),
            resolved: Vec::new(),
            net_class: Vec::new(),
            coupled: HashSet::new(),
            max_clearance: 0,
            deadline: None,
            search_weight: 1.2,
        }
    }
}

impl Rules {
    /// Reads a spec. Unknown keys are ignored, so a board's existing spec file works as it is.
    pub fn load(path: &str) -> Result<Rules, String> {
        let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
        let v: Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
        let mut r = Rules::default();
        let num = |k: &str| v.get(k).and_then(|x| x.as_f64());
        if let Some(x) = num("clearance") { r.clearance = mm(x); r.floor = mm(x); }
        if let Some(x) = num("track") { r.track = mm(x); }
        if let Some(x) = num("stub") { r.neck = mm(x); }
        if let Some(x) = num("viaSignal") { r.via = mm(x); }
        if let Some(x) = num("drillSignal") { r.drill = mm(x); }
        if let Some(x) = num("edgeClearance") { r.edge = mm(x); }
        if let Some(x) = v.pointer("/current/minPowerWidthMm").and_then(|x| x.as_f64()) { r.power_floor = mm(x); }
        if let Some(o) = v.get("wideNets").and_then(|x| x.as_object()) {
            for (k, a) in o {
                let w: Vec<i64> = a.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).map(mm).collect()).unwrap_or_default();
                r.wide.insert(k.clone(), w);
            }
        }
        if let Some(o) = v.get("impedanceTargets").and_then(|x| x.as_object()) {
            for t in o.values() {
                let (Some(p), Some(n)) = (t.pointer("/pair/0").and_then(|x| x.as_str()), t.pointer("/pair/1").and_then(|x| x.as_str())) else { continue };
                // Zero: the spec doesn't say; find_pairs takes it from the net class, or flags a default.
                let width = t.get("widthMm").and_then(|x| x.as_f64()).map(mm).unwrap_or(0);
                let gap = t.get("gapMm").and_then(|x| x.as_f64()).map(mm).unwrap_or(0);
                r.pairs.push(Pair { p: p.to_string(), n: n.to_string(), width, gap });
            }
        }
        if let Some(o) = v.get("rails").and_then(|x| x.as_object()) {
            for (net, x) in o {
                let list = |k: &str| x.get(k).and_then(|l| l.as_array()).map(|l| l.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default();
                let loads = x.get("loads").and_then(|l| l.as_object()).map(|m| m.iter().filter_map(|(k, a)| a.as_f64().map(|a| (k.clone(), a))).collect()).unwrap_or_default();
                r.rails.insert(net.clone(), RailSpec { sources: list("sources"), amps: x.get("amps").and_then(|a| a.as_f64()).unwrap_or(0.0), loads, volts: x.get("volts").and_then(|a| a.as_f64()).filter(|v| *v > 0.0) });
            }
        }
        if let Some(o) = v.get("loads").and_then(|x| x.as_object()) {
            for (k, l) in o {
                if let Some(a) = l.get("amps").and_then(|x| x.as_f64()) {
                    r.loads.insert(k.clone(), a);
                }
            }
        }
        r.max_rise = num("maxRiseC");
        if let Some(a) = v.get("routePriority").and_then(|x| x.as_array()) {
            r.priority = a.iter().filter_map(|x| x.as_str().map(String::from)).collect();
        }
        if let Some(o) = v.get("kelvin").and_then(|x| x.as_object()) {
            r.kelvin = o.keys().cloned().collect();
        }
        if let Some(o) = v.get("planes").and_then(|x| x.as_object()) {
            r.plane_nets = o.keys().cloned().collect();
        }
        // Parked parts are written as a list or as a map of reference to reason.
        match v.pointer("/placement/parked") {
            Some(Value::Array(a)) => r.skip_refs = a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
            Some(Value::Object(o)) => r.skip_refs = o.keys().cloned().collect(),
            _ => {}
        }
        if let Some(o) = v.pointer("/placement/innerFills").and_then(|x| x.as_object()) {
            for (fp, nets) in o {
                for n in nets.as_array().map(|a| a.as_slice()).unwrap_or(&[]) {
                    if let Some(n) = n.as_str() {
                        r.inner_fills.push((fp.clone(), n.to_string()));
                    }
                }
            }
        }
        Ok(r)
    }

    /// The spec's rail for this net name, whatever its case (+3v3, +3V3).
    pub fn rail(&self, net: &str) -> Option<&RailSpec> {
        self.rails.get(net).or_else(|| self.rails.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| v))
    }

    /// Width 4.2 asks for at this current: the conservative chart at the design's rise, 20 C by
    /// default. On a board under 3 x 3 in a main run also gets 1.25 x and the floor.
    pub fn rail_width(&self, amps: f64, main: bool) -> i64 {
        let w = crate::current::conservative_mm(amps, self.max_rise.unwrap_or(20.0), self.copper_um);
        let w = if main && self.small_board { (w * 1.25).max(to_mm(self.power_floor)) } else { w };
        mm((w * 100.0).ceil() / 100.0)
    }

    /// Width that holds the copper's voltage drop over `len` (nm) at `amps` to the limit he set
    /// (current.rs DROP_LIMIT of the rail's `volts`). Zero when any of them is missing.
    pub fn drop_width(&self, amps: f64, len: f64, volts: f64) -> i64 {
        if amps <= 0.0 || len <= 0.0 || volts <= 0.0 || self.copper_um <= 0.0 {
            return 0;
        }
        let w = crate::current::RHO_CU * (len * 1e-9) * amps / (self.copper_um * 1e-6 * volts * crate::current::DROP_LIMIT);
        mm((w * 1000.0 * 100.0).ceil() / 100.0)
    }

    /// Whether the spec records a current for this net.
    pub fn has_load(&self, net: &str) -> bool {
        self.loads.keys().any(|l| l.eq_ignore_ascii_case(net))
    }

    /// Works out every net's effective class on this board. Net ids belong to one parsed file, so
    /// call this again for another board; without it the `*_for` calls answer with the single
    /// strictest number in `clearance`, which is safe but pessimistic.
    pub fn bind(&mut self, board: &Board) {
        let (bw, bh) = (board.bbox.2 - board.bbox.0, board.bbox.3 - board.bbox.1);
        self.small_board = bw > 0.0 && bw < 76.2 * NM && bh < 76.2 * NM;
        self.copper_um = crate::current::outer_copper_um(&board.text);
        self.pad_exit.clear();
        let lowest = self.floor.max(self.min.clearance);
        for rule in &self.pad_rules {
            let Some(&net) = board.net_ids.get(&rule.net) else { continue };
            for (i, p) in board.pads.iter().enumerate() {
                if p.number == rule.pad && board.footprints[p.fp].reference == rule.reference {
                    self.pad_exit.insert((net, i as u32), rule.min.max(lowest));
                }
            }
        }
        self.resolved.clear();
        self.net_class = vec![0; board.nets.len()];
        self.coupled.clear();
        self.max_clearance = self.clearance.max(self.floor);
        if self.classes.is_empty() {
            return;
        }
        let mut seen: HashMap<String, u32> = HashMap::new();
        let names: Vec<String> = board.nets.clone();
        for (id, net) in names.iter().enumerate() {
            let matched = self.classes_of(net);
            let key = matched.join("\u{1}");
            let idx = match seen.get(&key) {
                Some(i) => *i,
                None => {
                    let i = self.resolved.len() as u32;
                    let res = self.resolve(&matched);
                    self.max_clearance = self.max_clearance.max(res.clearance);
                    self.resolved.push(res);
                    seen.insert(key, i);
                    i
                }
            };
            self.net_class[id] = idx;
        }
        for (i, n) in names.iter().enumerate() {
            if let Some(m) = pair_partner(n) {
                if let Some(&j) = board.net_ids.get(&m) {
                    let (a, b) = (i as NetId, j);
                    self.coupled.insert((a.min(b), a.max(b)));
                }
            }
        }
    }

    /// The class names a net belongs to, by explicit assignment and by pattern, best priority first.
    pub fn classes_of(&self, net: &str) -> Vec<String> {
        let mut names: Vec<&str> = Vec::new();
        if let Some(list) = self.assignments.get(net) {
            names.extend(list.iter().map(|s| s.as_str()));
        }
        names.extend(self.patterns.iter().filter(|(p, _)| wildcard(p, net)).map(|(_, c)| c.as_str()));
        let mut v: Vec<&NetClass> = self.classes.iter().filter(|c| names.contains(&c.name.as_str())).collect();
        v.sort_by_key(|c| c.priority);
        v.into_iter().map(|c| c.name.clone()).collect()
    }

    fn resolve(&self, matched: &[String]) -> Resolved {
        let def = self.classes.iter().find(|c| c.name == "Default");
        let mut chain: Vec<&NetClass> = matched.iter().filter_map(|m| self.classes.iter().find(|c| &c.name == m)).collect();
        chain.extend(def);
        let pick = |f: fn(&NetClass) -> Option<i64>, fallback: i64| chain.iter().find_map(|c| f(c)).unwrap_or(fallback);
        Resolved {
            name: chain.first().map(|c| c.name.clone()).unwrap_or_else(|| "Default".into()),
            clearance: pick(|c| c.clearance, self.floor),
            track: pick(|c| c.track, self.track),
            via_dia: pick(|c| c.via_dia, self.via),
            via_drill: pick(|c| c.via_drill, self.drill),
            pair_width: pick(|c| c.pair_width, 0),
            pair_gap: pick(|c| c.pair_gap, 0),
        }
    }

    pub fn class_of(&self, net: NetId) -> Option<&Resolved> {
        self.net_class.get(net as usize).and_then(|&i| self.resolved.get(i as usize))
    }

    /// Clearance between two nets, the way KiCad's DRC decides it: the larger of the two classes;
    /// a coupled differential pair may instead use its class's gap when that is smaller; and never
    /// under the board minimum or the designer's standing minimum (2.2).
    pub fn clearance_for(&self, a: NetId, b: NetId) -> i64 {
        let (Some(ca), Some(cb)) = (self.class_of(a), self.class_of(b)) else { return self.clearance };
        let mut c = ca.clearance.max(cb.clearance);
        if a != b && self.coupled.contains(&(a.min(b), a.max(b))) {
            let gap = ca.pair_gap.max(cb.pair_gap);
            if gap > 0 {
                c = c.min(gap);
            }
        }
        c.max(self.floor).max(self.min.clearance)
    }

    /// `clearance_for`, unless a pad rule lowers it for this net at this pad.
    pub fn clearance_at(&self, net: NetId, other: NetId, pad: Option<u32>) -> i64 {
        pad.and_then(|p| self.pad_exit.get(&(net, p)).copied()).unwrap_or_else(|| self.clearance_for(net, other))
    }

    /// The largest value `clearance_for` can return on the bound board: the reach for an index query.
    pub fn max_clearance(&self) -> i64 {
        self.max_clearance.max(self.clearance).max(self.floor)
    }

    /// The class's track width. A rail class may hold a current width (10 mm on the benchmark B): it
    /// is a default for drawing, not a limit.
    pub fn track_for(&self, net: NetId) -> i64 {
        self.class_of(net).map(|c| c.track).unwrap_or(self.track)
    }

    /// (diameter, drill) of the class's via.
    pub fn via_for(&self, net: NetId) -> (i64, i64) {
        self.class_of(net).map(|c| (c.via_dia, c.via_drill)).unwrap_or((self.via, self.drill))
    }

    /// (width, gap) of the class's differential pair, if it sets one.
    pub fn pair_for(&self, net: NetId) -> Option<(i64, i64)> {
        self.class_of(net).filter(|c| c.pair_width > 0 && c.pair_gap > 0).map(|c| (c.pair_width, c.pair_gap))
    }

    /// Copper of another net to a hole, plated or not. A project's min_hole_clearance when it has one.
    /// His document has no number for this: 2.2's 0.25 is hole to hole, a different rule. The 0.25
    /// here is the value KiCad projects carry untouched (134 of 176 of his project files, and the
    /// Benchmark C and benchmark B; v1.3 sets 0.2), so a board with no project is judged as KiCad would.
    pub fn hole_clearance(&self) -> i64 {
        if self.min.hole_clearance > 0 { self.min.hole_clearance } else { mm(0.25) }
    }

    /// Hole to hole, any net. The project's value is the one confirmed for this design; 2.2's 0.25
    /// is a starting value to confirm.
    pub fn hole_to_hole(&self) -> i64 {
        if self.min.hole_to_hole > 0 { self.min.hole_to_hole } else { self.hole_hole }
    }

    /// Finding severity for a KiCad rule name as this project sets it. A rule the project ignores
    /// stays visible as a warning: hiding it is not this tool's call.
    pub fn severity(&self, rule: &str) -> &'static str {
        match self.severities.get(rule).map(|s| s.as_str()) {
            Some("warning") | Some("ignore") | Some("exclusion") => "warn",
            _ => "error",
        }
    }
}

/// KiCad's pair naming: the same name ending in + and -, or P and N.
pub fn pair_partner(net: &str) -> Option<String> {
    let (stem, last) = net.split_at(net.len().checked_sub(1)?);
    let other = match last {
        "+" => "-",
        "-" => "+",
        "P" => "N",
        "N" => "P",
        _ => return None,
    };
    Some(format!("{stem}{other}"))
}

/// Anchored wildcard match with * and ?, as a netclass pattern is matched. Regular-expression
/// patterns (/.../) are not honoured; project.rs reports them.
pub fn wildcard(pat: &str, s: &str) -> bool {
    let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), s.chars().collect());
    let (mut i, mut j, mut star, mut mark) = (0usize, 0usize, None, 0usize);
    while j < t.len() {
        if i < p.len() && (p[i] == '?' || p[i] == t[j]) {
            i += 1;
            j += 1;
        } else if i < p.len() && p[i] == '*' {
            star = Some(i);
            mark = j;
            i += 1;
        } else if let Some(st) = star {
            i = st + 1;
            mark += 1;
            j = mark;
        } else {
            return false;
        }
    }
    while i < p.len() && p[i] == '*' {
        i += 1;
    }
    i == p.len()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wildcard_is_anchored() {
        assert!(wildcard("*USB2_D*", "/MCU_USB2_D+"));
        assert!(wildcard("+PS1_SENS", "+PS1_SENS"));
        assert!(!wildcard("-PS1", "-PS10"));
        assert!(wildcard("D?", "D1"));
        assert!(!wildcard("D?", "D12"));
    }

    #[test]
    fn pair_names() {
        assert_eq!(pair_partner("/MCU_USB2_D+").as_deref(), Some("/MCU_USB2_D-"));
        assert_eq!(pair_partner("USB.D_P").as_deref(), Some("USB.D_N"));
        assert_eq!(pair_partner("GND"), None);
    }

    fn board_with(nets: &[&str]) -> Board {
        let mut text = String::from("(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal))");
        for (i, n) in nets.iter().enumerate() {
            text.push_str(&format!(" (segment (start 0 {i}) (end 1 {i}) (width 0.2) (layer \"F.Cu\") (net \"{n}\"))"));
        }
        text.push(')');
        Board::parse(text).unwrap()
    }

    #[test]
    fn clearance_is_pairwise_and_keeps_the_floor() {
        // Benchmark B's classes: Default 0.2, USB 0.4 with a 0.12 pair gap, a rail at 0.5.
        let b = board_with(&["A", "B", "/USB_D+", "/USB_D-", "RAIL"]);
        let mut r = Rules::default();
        let cl = |name: &str, p: i64, c: f64, gap: Option<f64>| NetClass { name: name.into(), priority: p, clearance: Some(mm(c)), pair_gap: gap.map(mm), pair_width: gap.map(|_| mm(0.2)), ..Default::default() };
        r.classes = vec![cl("Default", i64::MAX, 0.2, Some(0.25)), cl("USB", 7, 0.4, Some(0.12)), cl("RAIL", 0, 0.5, None)];
        r.patterns = vec![("*USB_D*".into(), "USB".into()), ("RAIL".into(), "RAIL".into())];
        r.bind(&b);
        let id = |n: &str| b.net_ids[n];
        assert_eq!(r.clearance_for(id("A"), id("B")), mm(0.2));
        assert_eq!(r.clearance_for(id("A"), id("/USB_D+")), mm(0.4));
        assert_eq!(r.clearance_for(id("RAIL"), id("/USB_D+")), mm(0.5));
        // The coupled pair uses its gap, but 0.12 is under the standing 0.15, so 0.15.
        assert_eq!(r.clearance_for(id("/USB_D+"), id("/USB_D-")), mm(0.15));
        // Fields a class leaves out come from Default.
        assert_eq!(r.class_of(id("RAIL")).unwrap().pair_gap, mm(0.25));
        assert_eq!(r.max_clearance(), mm(0.5));
    }

    #[test]
    fn unbound_rules_answer_with_the_single_number() {
        let r = Rules { clearance: mm(0.17), ..Default::default() };
        assert_eq!(r.clearance_for(1, 2), mm(0.17));
    }
}