From 453d2042b9676c6cb1c7fc0ec3670828438087ec Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 24 Jul 2026 23:29:34 -0400 Subject: [PATCH] feat(ecad): fab-prep as one command, with the DRC delta as its receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking a routed board to a manufacturable Gerber package was a hand-run sequence of example binaries: certify the remaining connections, calibrate the rules, census the offending nets, strip them, re-route, repeat, prune the dead copper, export. New crate `vcad-ecad-fabprep` is that sequence, exposed as `vcad fab-prep` and the `fab_prep` MCP tool. The receipt is the point. On an imported fixture "zero DRC violations" is not achievable and anyone reporting it is reporting something else: the CM5 board stripped of all routing already scores 1,546 violations against its own land patterns. So the report never gives one number. It gives the pair — stripped baseline and finished board — and charges only the difference to the router, stating per rule how the difference was taken (set difference for geometric rules whose identity survives a strip; count difference for connectivity rules that adding copper legitimately rewrites). Two attribution subtleties the naive delta gets wrong, both fixed here: - Shorts propagate. If the fixture's own overlapping pads short X–A and Y–A, routing net A between its own pads merges the blobs and X–Y is newly reported — a pair the router did not cause. A final short is charged only when it bridges two *baseline* short groups. - The loop can "fix" DRC by deleting copper. Every geometric violation goes away if you remove the trace, and a board with no traces scores perfectly. Connectivity is now measured on arrival and on completion, and a run that bought cleanliness with unconnected nets does not converge. Rule calibration is opt-in and logged: it only relaxes a rule to the point where the board's own *given* geometry stops being illegal (declared via classes; pre-existing pad drills — never router-placed vias, which would let the routing justify its own rules), is floored at laser-microvia limits, and records its derivation. Hole-to-hole is deliberately not inferred from a via class — it is a process capability, not something a class declares. Waivers (`--accept MinTraceWidth`) make the exceptions a real fab package ships with explicit instead of prose: waived violations stay counted, stay listed, and are tagged WAIVED in the dump. An unrecognised rule name refuses the run rather than silently accepting nothing. Fails closed throughout: a non-converging run writes the receipt and the board, withholds every fabrication file, and exits 1. `export_gerber`'s clean-DRC gate is untouched — fab-prep is the supported way to get clean, not around it. Verified against the manual result: `vcad fab-prep board.pcb.json --calibrate-rules --accept MinTraceWidth --accept SameNetBypass` reproduces ~/Downloads/cm5-vcad-fab byte-for-byte across all 27 fabrication artifacts (21 gerbers, drill.drl, board.kicad_pcb, bom.csv, pick_place.csv, board.svg), replacing the hand-written FAB_NOTES.md/drc_report.txt with the generated receipt and adding machine-readable fab_report.json. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 11 + Cargo.lock | 18 + Cargo.toml | 3 + ...2026-07-24-fab-prep-drc-delta-receipt.json | 10 + crates/vcad-cli/Cargo.toml | 5 + crates/vcad-cli/src/fabprep.rs | 174 ++++ crates/vcad-cli/src/main.rs | 89 ++ crates/vcad-ecad-fabprep/Cargo.toml | 26 + crates/vcad-ecad-fabprep/src/calibrate.rs | 508 ++++++++++ crates/vcad-ecad-fabprep/src/delta.rs | 646 +++++++++++++ crates/vcad-ecad-fabprep/src/lib.rs | 894 ++++++++++++++++++ crates/vcad-ecad-fabprep/src/package.rs | 259 +++++ crates/vcad-ecad-fabprep/src/render.rs | 513 ++++++++++ crates/vcad-ecad-fabprep/src/test_support.rs | 141 +++ crates/vcad-ecad-fabprep/src/verdict.rs | 383 ++++++++ crates/vcad-kernel-wasm/Cargo.toml | 10 +- crates/vcad-kernel-wasm/src/lib.rs | 42 + packages/engine/src/ecad.ts | 106 +++ packages/engine/src/index.ts | 3 + packages/mcp/src/__tests__/ecad.test.ts | 75 ++ .../src/__tests__/tool-surface.fixture.json | 66 ++ packages/mcp/src/server.ts | 1 + packages/mcp/src/tools/ecad.ts | 218 ++++- packages/mcp/src/tools/tool-metadata.ts | 1 + 24 files changed, 4200 insertions(+), 2 deletions(-) create mode 100644 changelog/entries/2026-07-24-fab-prep-drc-delta-receipt.json create mode 100644 crates/vcad-cli/src/fabprep.rs create mode 100644 crates/vcad-ecad-fabprep/Cargo.toml create mode 100644 crates/vcad-ecad-fabprep/src/calibrate.rs create mode 100644 crates/vcad-ecad-fabprep/src/delta.rs create mode 100644 crates/vcad-ecad-fabprep/src/lib.rs create mode 100644 crates/vcad-ecad-fabprep/src/package.rs create mode 100644 crates/vcad-ecad-fabprep/src/render.rs create mode 100644 crates/vcad-ecad-fabprep/src/test_support.rs create mode 100644 crates/vcad-ecad-fabprep/src/verdict.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4895dc80b..8e2ddc4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,6 +372,11 @@ phyz-based articulated physics via `vcad-kernel-physics`: vcad export input.vcad output.stl # Export to STL/GLB/STEP vcad import-step input.step out.vcad vcad info input.vcad # Show document info + +# Routed board → complete fab package + DRC-delta receipt, in one command. +# Calibration is opt-in and logged; the loop fails closed (exit 1, no +# fabrication files) if route-attributable violations don't reach zero. +vcad fab-prep routed.pcb.json -o out/ --calibrate-rules ``` **Static SVG renderer:** [`vcad-render`](crates/vcad-render) projects a `.vcad` to a drafting-style isometric SVG. Used by the mecheval leaderboard, but standalone — handy for docs, marketing, and README diagrams. @@ -391,6 +396,12 @@ target/debug/vcad-render path/to/part.vcad > out.svg - `topology_optimize` — SIMP topology optimization: stiffest material layout for given loads/supports inside a box envelope or an existing part's volume; result lands in the document as a frozen mesh part +- `fab_prep` — routed board → fab-ready, in one call: opt-in (logged) rule + calibration, verdict ladder, strip-and-re-route fix loop, dangling-copper + prune. Returns a DRC-delta receipt reporting route-attributable violations + against the SAME board stripped of all routing — absolute zero is not + achievable on an imported fixture, so both numbers are always given. Fails + closed; `export_gerber`'s clean-DRC gate still stands - `verify_part` / `list_eval_tasks` — grade the document against mecheval benchmark tasks via the official `mecheval-grade` binary (self-grading oracle; the benchmark harness excludes these during scored runs) diff --git a/Cargo.lock b/Cargo.lock index bd6e887d2..d404701de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6883,6 +6883,7 @@ dependencies = [ "axum", "clap", "crossterm", + "env_logger", "futures-util", "libc", "rustyline", @@ -6897,6 +6898,7 @@ dependencies = [ "vcad-chat", "vcad-crdt", "vcad-diff", + "vcad-ecad-fabprep", "vcad-eval", "vcad-i18n", "vcad-ir", @@ -6908,6 +6910,7 @@ dependencies = [ "vcad-kernel-sketch", "vcad-kernel-tessellate", "vcad-kernel-urdf", + "vcad-render", "vcad-slicer", "vcad-slicer-bambu", "vcad-slicer-gcode", @@ -6997,6 +7000,20 @@ dependencies = [ "vcad-ir", ] +[[package]] +name = "vcad-ecad-fabprep" +version = "0.9.4" +dependencies = [ + "log", + "serde", + "serde_json", + "thiserror 2.0.18", + "vcad-ecad-export", + "vcad-ecad-pcb", + "vcad-ecad-symbols", + "vcad-ir", +] + [[package]] name = "vcad-ecad-package" version = "0.9.4" @@ -7740,6 +7757,7 @@ dependencies = [ "vcad-design-constraints", "vcad-diff", "vcad-ecad-export", + "vcad-ecad-fabprep", "vcad-ecad-parts", "vcad-ecad-pcb", "vcad-ecad-schematic", diff --git a/Cargo.toml b/Cargo.toml index 3e1a440e7..6f2e871ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ members = [ "crates/vcad-ecad-verify", "crates/vcad-ecad-source", "crates/vcad-ecad-export", + "crates/vcad-ecad-fabprep", "crates/vcad-ecad-sim", "crates/vcad-ecad-diff", "crates/vcad-ecad-diff-wasm", @@ -171,6 +172,7 @@ default-members = [ "crates/vcad-ecad-verify", "crates/vcad-ecad-source", "crates/vcad-ecad-export", + "crates/vcad-ecad-fabprep", "crates/vcad-ecad-sim", "crates/vcad-ecad-diff", "crates/vcad-ecad-diff-wasm", @@ -285,6 +287,7 @@ vcad-ecad-pcb = { path = "crates/vcad-ecad-pcb", version = "0.9.4" } vcad-ecad-verify = { path = "crates/vcad-ecad-verify", version = "0.9.4" } vcad-ecad-source = { path = "crates/vcad-ecad-source", version = "0.9.4" } vcad-ecad-export = { path = "crates/vcad-ecad-export", version = "0.9.4" } +vcad-ecad-fabprep = { path = "crates/vcad-ecad-fabprep", version = "0.9.4" } vcad-ecad-sim = { path = "crates/vcad-ecad-sim", version = "0.9.4" } vcad-ecad-diff = { path = "crates/vcad-ecad-diff", version = "0.9.4" } nom = "7" diff --git a/changelog/entries/2026-07-24-fab-prep-drc-delta-receipt.json b/changelog/entries/2026-07-24-fab-prep-drc-delta-receipt.json new file mode 100644 index 000000000..3256f1119 --- /dev/null +++ b/changelog/entries/2026-07-24-fab-prep-drc-delta-receipt.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-24-fab-prep-drc-delta-receipt", + "version": "0.9.4", + "date": "2026-07-24", + "category": "feat", + "title": "One-command fab prep with a DRC-delta receipt", + "summary": "vcad fab-prep (and the fab_prep MCP tool) takes a routed board to a complete Gerber package in one call: opt-in logged rule calibration, verdict ladder, strip-and-re-route fix loop, dangling-copper prune, and a receipt that separates route-attributable violations from the board's own baseline.", + "features": ["pcb", "drc", "autorouter", "fabrication", "cli"], + "mcpTools": ["fab_prep", "validate_for_fab", "export_gerber"] +} diff --git a/crates/vcad-cli/Cargo.toml b/crates/vcad-cli/Cargo.toml index 3bd7e48cb..0d48dd8d3 100644 --- a/crates/vcad-cli/Cargo.toml +++ b/crates/vcad-cli/Cargo.toml @@ -28,6 +28,11 @@ vcad-kernel-physics = { path = "../vcad-kernel-physics" } vcad-kernel-cam = { path = "../vcad-kernel-cam" } vcad-kernel-raytrace = { path = "../vcad-kernel-raytrace" } vcad-kernel-tessellate = { workspace = true } +vcad-ecad-fabprep = { workspace = true } +# fab-prep runs for minutes on a dense board and narrates its rounds through +# `log`; without a subscriber the terminal shows nothing until it finishes. +env_logger = "0.11" +vcad-render = { path = "../vcad-render", default-features = false } vcad-slicer = { path = "../vcad-slicer" } vcad-slicer-gcode = { path = "../vcad-slicer-gcode" } vcad-slicer-bambu = { path = "../vcad-slicer-bambu", default-features = false } diff --git a/crates/vcad-cli/src/fabprep.rs b/crates/vcad-cli/src/fabprep.rs new file mode 100644 index 000000000..50707bd3a --- /dev/null +++ b/crates/vcad-cli/src/fabprep.rs @@ -0,0 +1,174 @@ +//! `vcad fab-prep` — routed board in, fab package plus DRC-delta receipt out. +//! +//! The command wrapper is deliberately thin: everything that decides anything +//! lives in [`vcad_ecad_fabprep`]. What belongs here is the part a CLI owes its +//! caller — a progress narration, and an exit code that means what it says. +//! A run that does not converge prints the offenders, writes the receipt and +//! the board, withholds every fabrication file, and exits non-zero. + +use std::path::Path; + +use anyhow::{Context, Result}; +use vcad_ecad_fabprep::{package, render, run_fab_prep, FabPrepOptions, VerdictOptions}; +use vcad_ir::ecad::{Pcb, PcbLayer}; + +/// Options as the CLI surface exposes them. +pub struct FabPrepArgs { + /// Input `.pcb.json`. + pub input: std::path::PathBuf, + /// Output directory for the package. + pub output: std::path::PathBuf, + /// Derive and apply rule calibration (opt-in). + pub calibrate_rules: bool, + /// Skip the opening verdict pass. + pub skip_routing: bool, + /// Skip the dangling-copper prune. + pub skip_prune: bool, + /// Maximum fix-loop rounds. + pub max_rounds: usize, + /// Per-cluster search budget. + pub budget: usize, + /// Maximum connections per joint search window. + pub max_cluster: usize, + /// Write the receipt only — never fabrication files, even on success. + pub report_only: bool, + /// Skip the board SVG (the slowest optional artifact on a dense board). + pub skip_svg: bool, + /// DRC rules whose route-attributable violations are accepted rather than + /// fixed. Logged in the receipt; an unknown name refuses the run. + pub accept: Vec, +} + +/// Run the pipeline and write the package. Returns `Ok(false)` when the run did +/// not converge — the caller turns that into a non-zero exit. +pub fn run(args: &FabPrepArgs) -> Result { + // Rounds are minutes apart on a dense board; narrate them by default. + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .try_init(); + + let text = std::fs::read_to_string(&args.input) + .with_context(|| format!("reading {}", args.input.display()))?; + let mut pcb: Pcb = serde_json::from_str(&text) + .with_context(|| format!("parsing {} as a .pcb.json board", args.input.display()))?; + + println!( + "fab-prep: {} — {} footprints, {} traces, {} vias", + args.input.display(), + pcb.footprints.len(), + pcb.traces.len(), + pcb.vias.len() + ); + if args.calibrate_rules { + println!("fab-prep: rule calibration ENABLED — every change is logged in the receipt"); + } + if !args.accept.is_empty() { + println!( + "fab-prep: WAIVING {} — those violations are still counted and listed, they just \ + stop blocking the verdict", + args.accept.join(", ") + ); + } + + let opts = FabPrepOptions { + calibrate_rules: args.calibrate_rules, + route_remaining: !args.skip_routing, + max_rounds: args.max_rounds, + prune_dangling: !args.skip_prune, + accept_rules: args.accept.clone(), + verdict: VerdictOptions { + budget: args.budget, + max_cluster: args.max_cluster, + }, + }; + let outcome = run_fab_prep(&mut pcb, &opts); + + // Narrate the verdict to stdout so a terminal run is readable without + // opening the files. The full violation dump stays in drc_report.txt. + print!("\n{}", render::summary(&outcome.report)); + + if args.report_only { + let written = package::write_report(&args.output, &pcb, &outcome) + .context("writing the fab-prep report")?; + println!( + "\nfab-prep: report-only — wrote {} file(s) to {}", + written.len(), + args.output.display() + ); + return Ok(outcome.report.converged); + } + + // Copper-first inspection render: copper layers plus the outline, no value + // or net labels. On a 479-footprint board the refdes text is a wall of + // glyphs that buries the copper, and the copper is what a fab-prep reviewer + // is looking at. Zone pours are dropped because rendering one runs a 2D + // boolean per zone and dominates the run. + let svg = (!args.skip_svg && outcome.report.converged).then(|| { + let layers: Vec = pcb + .stackup + .layers + .iter() + .map(|l| l.layer) + .filter(|l| l.is_copper()) + .chain(std::iter::once(PcbLayer::EdgeCuts)) + .collect(); + let mut flat = pcb.clone(); + flat.zones.clear(); + vcad_render::pcb::render_pcb_svg_opts( + &flat, + &layers, + 12.0, + &vcad_render::pcb::PcbRenderOpts { + show_values: false, + show_net_labels: false, + ..Default::default() + }, + ) + }); + + match package::write_fab_package(&args.output, &pcb, &outcome, svg.as_deref()) { + Ok(written) => { + println!( + "\nfab-prep: {}\nfab-prep: wrote {} file(s) to {}", + outcome.report.headline(), + written.len(), + args.output.display() + ); + Ok(true) + } + Err(package::PackageError::NotConverged(msg)) => { + eprintln!("\nfab-prep: FAIL-CLOSED — {msg}"); + Ok(false) + } + Err(e) => Err(anyhow::Error::new(e).context("writing the fab package")), + } +} + +/// Default output directory next to the input when none is given. +pub fn default_output_dir(input: &Path) -> std::path::PathBuf { + let stem = input + .file_stem() + .map(|s| s.to_string_lossy().replace(".pcb", "")) + .unwrap_or_else(|| "board".into()); + input + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{stem}-fab")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_output_sits_beside_the_input() { + assert_eq!( + default_output_dir(Path::new("/tmp/cm5.pcb.json")), + Path::new("/tmp/cm5-fab") + ); + assert_eq!( + default_output_dir(Path::new("board.json")), + Path::new("board-fab") + ); + } +} diff --git a/crates/vcad-cli/src/main.rs b/crates/vcad-cli/src/main.rs index 8520d8e5a..5a0c084df 100644 --- a/crates/vcad-cli/src/main.rs +++ b/crates/vcad-cli/src/main.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; mod app; mod chat_session; +mod fabprep; mod input; mod keybinding_adapter; mod log_capture; @@ -181,6 +182,61 @@ enum Commands { file: PathBuf, }, + /// Take a routed .pcb.json to a complete fab package plus a DRC-delta receipt + /// + /// Runs the whole fab-prep pipeline in one command: (optionally) calibrate + /// the board's design rules from its own declared via classes, route or + /// certify the remaining connections, then loop — census the violations the + /// routing is answerable for, strip their nets, re-route through the + /// session-probed ladder — until that number reaches zero. Prunes dangling + /// copper, then exports Gerbers, drill, KiCad board, BOM and pick-and-place. + /// + /// The receipt reports route-attributable violations against the SAME board + /// stripped of all routing, because absolute zero is not achievable on an + /// imported fixture. If the loop does not converge, the offenders are + /// reported, no fabrication files are written, and the exit code is 1. + FabPrep { + /// Routed board (.pcb.json) + input: PathBuf, + /// Output directory (default: -fab next to the input) + #[arg(short, long)] + output: Option, + /// Derive and apply design-rule calibration from the board's own + /// declared via classes and pre-existing holes. OFF by default: + /// silently relaxing DRC rules to make a board pass is a footgun. + /// Every change is logged with its derivation in the receipt. + #[arg(long)] + calibrate_rules: bool, + /// Skip the opening verdict pass (take the board as fully routed) + #[arg(long)] + skip_routing: bool, + /// Keep copper that reaches no pad or pour of its net + #[arg(long)] + skip_prune: bool, + /// Skip the board SVG (the slowest optional artifact on a dense board) + #[arg(long)] + skip_svg: bool, + /// Maximum strip-and-re-route rounds + #[arg(long, default_value = "8")] + max_rounds: usize, + /// Per-cluster search budget (node expansions) for the complete router + #[arg(long, default_value = "5000000")] + budget: usize, + /// Maximum connections coalesced into one joint search window + #[arg(long, default_value = "6")] + max_cluster: usize, + /// Write the receipt and board only — never fabrication files + #[arg(long)] + report_only: bool, + /// Accept a DRC rule's route-attributable violations instead of fixing + /// them (repeatable, e.g. --accept MinTraceWidth). The violations are + /// still counted and listed in the receipt and the waiver is named in + /// FAB_NOTES.md — it stops blocking the verdict, it does not hide + /// anything. An unrecognised rule name refuses the run. + #[arg(long = "accept", value_name = "RULE")] + accept: Vec, + }, + /// Run a physics simulation on a robot assembly (.vcad or .urdf) Simulate { /// Input file (.vcad or .urdf). URDFs are imported in-memory. @@ -355,6 +411,39 @@ fn main() -> Result<()> { Some(Commands::Info { file }) => { show_info(&file)?; } + Some(Commands::FabPrep { + input, + output, + calibrate_rules, + skip_routing, + skip_prune, + skip_svg, + max_rounds, + budget, + max_cluster, + report_only, + accept, + }) => { + let output = output.unwrap_or_else(|| fabprep::default_output_dir(&input)); + let converged = fabprep::run(&fabprep::FabPrepArgs { + input, + output, + calibrate_rules, + skip_routing, + skip_prune, + skip_svg, + max_rounds, + budget, + max_cluster, + report_only, + accept, + })?; + // Fail closed: a board that did not converge must not look like a + // successful run to a script or a CI step. + if !converged { + std::process::exit(1); + } + } Some(Commands::Simulate { input, steps, diff --git a/crates/vcad-ecad-fabprep/Cargo.toml b/crates/vcad-ecad-fabprep/Cargo.toml new file mode 100644 index 000000000..b0dc74128 --- /dev/null +++ b/crates/vcad-ecad-fabprep/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vcad-ecad-fabprep" +description = "Reproducible PCB fab preparation: rule calibration, DRC fix loop, dangling-copper prune, and the DRC-delta receipt" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +vcad-ir = { workspace = true } +vcad-ecad-pcb = { workspace = true } +vcad-ecad-export = { workspace = true, optional = true } +vcad-ecad-symbols = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = "0.4" + +[features] +default = ["package"] +# Writing a fabrication package to disk. Off in the WASM kernel, which runs the +# pipeline and hands the fixed board back to the session — file export there is +# `export_gerber`'s job, and pulling the exporters in would grow the bundle for +# nothing. +package = ["dep:vcad-ecad-export", "dep:vcad-ecad-symbols"] diff --git a/crates/vcad-ecad-fabprep/src/calibrate.rs b/crates/vcad-ecad-fabprep/src/calibrate.rs new file mode 100644 index 000000000..3f37d0860 --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/calibrate.rs @@ -0,0 +1,508 @@ +//! Opt-in, fully-logged design-rule calibration. +//! +//! Imported boards routinely carry global minima that contradict their own +//! declared via classes: the CM5 reverse-engineering fixture declares a +//! 0.21/0.12 mm default via class and a global `minDrill` of 0.2 mm, so every +//! via the board itself specifies is illegal under the board's own rules — +//! 832 AnnularRing + MinDrill violations that no amount of routing can fix. +//! +//! Relaxing DRC rules to make a board pass is a serious footgun, so this +//! module is built around three guarantees: +//! +//! 1. **Opt-in.** Nothing here runs unless the caller asks for it. +//! 2. **Derived, never invented.** Every calibrated value comes from geometry +//! the board already carries and the router did not create — a *declared* +//! via class, or a pre-existing footprint hole pair. A rule can only be +//! relaxed to the point where the board's own given geometry stops being +//! illegal, never further. +//! 3. **Logged with its derivation.** Each change records the declared value, +//! the calibrated value, and the evidence sentence +//! ("minDrill 0.12 from via class 'Default' (0.21/0.12 mm), realized by +//! 1,867 vias") so a reader can audit the policy instead of trusting it. +//! +//! Calibration is also floored: a corrupt board cannot talk the rules below +//! the physical limits of the tightest process this codebase is willing to +//! name ([`FLOOR_DRILL`], [`FLOOR_ANNULAR_RING`], [`FLOOR_HOLE_TO_HOLE`] — +//! laser-microvia class). A derivation that would go under the floor is +//! refused and the refusal is reported. +//! +//! # What is deliberately *not* derived +//! +//! `minDrill` and `minAnnularRing` are properties of a via class, so a board +//! that declares a class is already stating them and a contradiction is a +//! genuine self-inconsistency this module can resolve. Hole-to-hole spacing is +//! not: it is a **process capability** of whoever drills the board, and no via +//! class declares it. So `holeToHole` is only ever relaxed to admit holes the +//! board already contains and the router did not create — pad drills in the +//! imported land patterns. It is never inferred from vias, because on a re-run +//! the vias are the router's own output and the rule would end up justifying +//! the copper it exists to judge. +//! +//! If a board's declared `holeToHole` is wrong for its fab, the fix is to state +//! the correct rule on the board — a decision with a named owner — not to have +//! this module guess one. Until then the violations stand, the fix loop tries to +//! route around them, and a run that cannot fails closed. + +use std::collections::BTreeMap; + +use vcad_ir::ecad::{NetClassRules, Pcb}; + +/// Smallest drill this crate will ever calibrate `minDrill` down to (mm). +/// Laser-microvia class; below this is not a drilled hole any fab quotes. +pub const FLOOR_DRILL: f64 = 0.05; + +/// Smallest annular ring this crate will ever calibrate to (mm). +pub const FLOOR_ANNULAR_RING: f64 = 0.02; + +/// Smallest hole-to-hole gap this crate will ever calibrate to (mm). +pub const FLOOR_HOLE_TO_HOLE: f64 = 0.05; + +/// One applied design-rule calibration, with the derivation that justifies it. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RuleCalibration { + /// Rule field name as it appears in the `.pcb.json` (`minDrill`, + /// `minAnnularRing`, `holeToHole`). + pub rule: String, + /// The value the board declared before calibration (mm). + pub declared: f64, + /// The value calibration relaxed it to (mm). + pub calibrated: f64, + /// Human-readable derivation: where the number came from and how much of + /// the board's given geometry vouches for it. + pub justification: String, +} + +/// A calibration that was derived but **refused** — the derivation demanded a +/// value below the physical floor, so the declared rule stands and the board +/// keeps whatever violations that implies. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RefusedCalibration { + /// Rule field name. + pub rule: String, + /// The value the derivation asked for (mm). + pub requested: f64, + /// The floor that refused it (mm). + pub floor: f64, + /// Why the request was refused. + pub reason: String, +} + +/// The outcome of a calibration pass: what changed, and what was refused. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct CalibrationReport { + /// Applied calibrations, in rule order. + pub applied: Vec, + /// Derivations that hit a floor and were refused. + pub refused: Vec, +} + +impl CalibrationReport { + /// True when nothing was applied and nothing was refused. + pub fn is_empty(&self) -> bool { + self.applied.is_empty() && self.refused.is_empty() + } +} + +/// Round to 1 µm — fab rules are quoted in whole microns, and an unrounded +/// float derivation ("0.11999999999999998") reads as noise in the receipt. +fn micron(v: f64) -> f64 { + (v * 1000.0).round() / 1000.0 +} + +/// The via class this board actually builds with: among the *declared* +/// classes (default + per-net-class overrides), the one whose +/// (diameter, drill) pair the most realized vias match. +/// +/// Selecting among declared classes — rather than measuring the vias +/// directly — is what keeps calibration non-circular. The router places vias +/// at the default class, so a measurement-based derivation would let the +/// router justify its own rules; a declaration-based one can only ever surface +/// a contradiction the *board author* wrote down. +#[derive(Debug, Clone, PartialEq)] +pub struct ViaClassUsage { + /// Class name. + pub name: String, + /// Declared via diameter (mm). + pub diameter: f64, + /// Declared via drill (mm). + pub drill: f64, + /// How many realized vias match this class's (diameter, drill). + pub realized: usize, +} + +/// Every declared via class on the board, annotated with how many realized +/// vias match it, most-used first (ties broken by name for determinism). +pub fn via_class_usage(pcb: &Pcb) -> Vec { + let mut classes: Vec<&NetClassRules> = vec![&pcb.rules.default_rules]; + classes.extend(pcb.rules.class_rules.iter()); + + let mut counts: BTreeMap = BTreeMap::new(); + for via in &pcb.vias { + for c in &classes { + if (via.diameter - c.via_diameter).abs() < 1e-9 + && (via.drill - c.via_drill).abs() < 1e-9 + { + *counts.entry(c.name.clone()).or_default() += 1; + } + } + } + + let mut out: Vec = classes + .iter() + .map(|c| ViaClassUsage { + name: c.name.clone(), + diameter: c.via_diameter, + drill: c.via_drill, + realized: counts.get(&c.name).copied().unwrap_or(0), + }) + .collect(); + out.sort_by(|a, b| { + b.realized + .cmp(&a.realized) + .then_with(|| a.name.cmp(&b.name)) + }); + out +} + +/// The tightest hole-to-hole gap among the board's **pre-existing** holes — +/// footprint pad drills, which the router never creates. Returns the gap (mm) +/// and a label for the pair that attains it. +/// +/// Vias are deliberately excluded: they are (or may be) the router's own +/// output, and a rule calibrated against router output would exempt exactly +/// the copper it is supposed to be judging. +fn tightest_pad_hole_gap(pcb: &Pcb) -> Option<(f64, String)> { + struct Hole { + x: f64, + y: f64, + r: f64, + label: String, + } + let mut holes: Vec = Vec::new(); + for fp in &pcb.footprints { + for pad in &fp.pads { + let Some(spec) = &pad.drill else { continue }; + let drill = spec.diameter; + if drill <= 0.0 { + continue; + } + let p = vcad_ecad_pcb::geometry::pad_world_position(fp, pad); + holes.push(Hole { + x: p.x, + y: p.y, + r: drill / 2.0, + label: format!("{}.{}", fp.reference, pad.number), + }); + } + } + if holes.len() < 2 { + return None; + } + + // Sort by x so the O(n^2) pair scan can break out early: once the x-gap + // alone exceeds the best edge-to-edge gap found so far, no later pair can + // beat it. Imported boards carry thousands of THT holes. + holes.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal)); + let mut best: Option<(f64, String)> = None; + for i in 0..holes.len() { + for j in (i + 1)..holes.len() { + let dx = holes[j].x - holes[i].x; + if let Some((b, _)) = &best { + if dx - holes[i].r - holes[j].r > *b { + break; + } + } + let dy = holes[j].y - holes[i].y; + let gap = (dx * dx + dy * dy).sqrt() - holes[i].r - holes[j].r; + if best.as_ref().is_none_or(|(b, _)| gap < *b) { + best = Some((gap, format!("{}↔{}", holes[i].label, holes[j].label))); + } + } + } + best.map(|(gap, label)| (gap.max(0.0), label)) +} + +/// Derive and apply rule calibration to `pcb` in place, returning the log. +/// +/// Only rules the board's own given geometry *contradicts* are touched, and +/// only ever in the relaxing direction. A board whose rules already admit its +/// declared classes and pre-existing holes comes back with an empty report and +/// an unmodified `pcb`. +pub fn calibrate_rules(pcb: &mut Pcb) -> CalibrationReport { + let mut report = CalibrationReport::default(); + let usage = via_class_usage(pcb); + + // --- minDrill -------------------------------------------------------- + // The board is illegal against itself if any declared class drills + // smaller than the global minimum. Relax to the smallest declared drill; + // cite the class that attains it and how much of the board realizes it. + if let Some(min_class) = usage.iter().min_by(|a, b| { + a.drill + .partial_cmp(&b.drill) + .unwrap_or(std::cmp::Ordering::Equal) + }) { + let want = micron(min_class.drill); + if want < pcb.rules.min_drill { + if want < FLOOR_DRILL { + report.refused.push(RefusedCalibration { + rule: "minDrill".into(), + requested: want, + floor: FLOOR_DRILL, + reason: format!( + "via class '{}' declares a {want:.3}mm drill, below the {FLOOR_DRILL:.3}mm \ + laser-microvia floor — the declared rule stands", + min_class.name + ), + }); + } else { + report.applied.push(RuleCalibration { + rule: "minDrill".into(), + declared: pcb.rules.min_drill, + calibrated: want, + justification: format!( + "minDrill {want:.3} from via class '{}' ({:.3}/{:.3} mm), realized by {} \ + vias — the board's global minimum ({:.3}) forbids the via class the board \ + itself declares", + min_class.name, + min_class.diameter, + min_class.drill, + min_class.realized, + pcb.rules.min_drill, + ), + }); + pcb.rules.min_drill = want; + } + } + } + + // --- minAnnularRing -------------------------------------------------- + if let Some(min_class) = usage.iter().min_by(|a, b| { + let (ra, rb) = ((a.diameter - a.drill) / 2.0, (b.diameter - b.drill) / 2.0); + ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal) + }) { + let want = micron((min_class.diameter - min_class.drill) / 2.0); + if want < pcb.rules.min_annular_ring { + if want < FLOOR_ANNULAR_RING { + report.refused.push(RefusedCalibration { + rule: "minAnnularRing".into(), + requested: want, + floor: FLOOR_ANNULAR_RING, + reason: format!( + "via class '{}' implies a {want:.3}mm ring, below the \ + {FLOOR_ANNULAR_RING:.3}mm floor — the declared rule stands", + min_class.name + ), + }); + } else { + report.applied.push(RuleCalibration { + rule: "minAnnularRing".into(), + declared: pcb.rules.min_annular_ring, + calibrated: want, + justification: format!( + "minAnnularRing {want:.3} = ({:.3} − {:.3}) / 2 from via class '{}', \ + realized by {} vias — the board's global minimum ({:.3}) forbids its own \ + via class", + min_class.diameter, + min_class.drill, + min_class.name, + min_class.realized, + pcb.rules.min_annular_ring, + ), + }); + pcb.rules.min_annular_ring = want; + } + } + } + + // --- holeToHole ------------------------------------------------------ + // No class declares a hole-to-hole minimum, so this one is measured — but + // only over holes the router did not create (footprint pad drills). It + // exempts the fixture's own land patterns, never the routing. + if let Some((gap, pair)) = tightest_pad_hole_gap(pcb) { + let want = micron(gap); + if want < pcb.rules.hole_to_hole { + if want < FLOOR_HOLE_TO_HOLE { + report.refused.push(RefusedCalibration { + rule: "holeToHole".into(), + requested: want, + floor: FLOOR_HOLE_TO_HOLE, + reason: format!( + "pre-existing hole pair {pair} sits at {want:.3}mm, below the \ + {FLOOR_HOLE_TO_HOLE:.3}mm floor — the declared rule stands and that pair \ + stays a reported violation" + ), + }); + } else { + report.applied.push(RuleCalibration { + rule: "holeToHole".into(), + declared: pcb.rules.hole_to_hole, + calibrated: want, + justification: format!( + "holeToHole {want:.3} from the tightest pre-existing footprint hole pair \ + ({pair}) — pad drills the router did not create; the board's global \ + minimum ({:.3}) forbids its own land patterns", + pcb.rules.hole_to_hole, + ), + }); + pcb.rules.hole_to_hole = want; + } + } + } + + report +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_board, with_tht_pad}; + + #[test] + fn contradictory_via_class_is_calibrated_and_logged() { + let mut pcb = test_board(); + // Board declares a 0.21/0.12 via class but a 0.20mm global minDrill. + pcb.rules.default_rules.via_diameter = 0.21; + pcb.rules.default_rules.via_drill = 0.12; + pcb.rules.min_drill = 0.20; + pcb.rules.min_annular_ring = 0.15; + + let report = calibrate_rules(&mut pcb); + assert_eq!(pcb.rules.min_drill, 0.12); + assert_eq!(pcb.rules.min_annular_ring, 0.045); + + let drill = report + .applied + .iter() + .find(|c| c.rule == "minDrill") + .expect("minDrill calibrated"); + assert_eq!(drill.declared, 0.20); + assert_eq!(drill.calibrated, 0.12); + assert!( + drill.justification.contains("0.210/0.120 mm"), + "derivation must name the via class: {}", + drill.justification + ); + } + + #[test] + fn realized_via_count_appears_in_the_derivation() { + let mut pcb = test_board(); + pcb.rules.default_rules.via_diameter = 0.21; + pcb.rules.default_rules.via_drill = 0.12; + pcb.rules.min_drill = 0.20; + for i in 0..3 { + pcb.vias.push(vcad_ir::ecad::Via { + position: vcad_ir::Vec2::new(2.0 + i as f64, 2.0), + diameter: 0.21, + drill: 0.12, + start_layer: vcad_ir::ecad::PcbLayer::FCu, + end_layer: vcad_ir::ecad::PcbLayer::BCu, + net: "N1".into(), + source: None, + }); + } + let report = calibrate_rules(&mut pcb); + let drill = report + .applied + .iter() + .find(|c| c.rule == "minDrill") + .unwrap(); + assert!( + drill.justification.contains("realized by 3 vias"), + "{}", + drill.justification + ); + } + + #[test] + fn consistent_rules_are_left_alone() { + let mut pcb = test_board(); + pcb.rules.default_rules.via_diameter = 0.6; + pcb.rules.default_rules.via_drill = 0.3; + pcb.rules.min_drill = 0.2; + pcb.rules.min_annular_ring = 0.1; + pcb.rules.hole_to_hole = 0.2; + + let report = calibrate_rules(&mut pcb); + assert!(report.is_empty(), "nothing to calibrate: {report:?}"); + assert_eq!(pcb.rules.min_drill, 0.2); + } + + #[test] + fn calibration_never_tightens_a_rule() { + let mut pcb = test_board(); + // Declared class is far looser than the global minimum: no change. + pcb.rules.default_rules.via_diameter = 1.0; + pcb.rules.default_rules.via_drill = 0.6; + pcb.rules.min_drill = 0.1; + pcb.rules.min_annular_ring = 0.05; + + calibrate_rules(&mut pcb); + assert_eq!(pcb.rules.min_drill, 0.1); + assert_eq!(pcb.rules.min_annular_ring, 0.05); + } + + #[test] + fn a_sub_floor_derivation_is_refused_not_applied() { + let mut pcb = test_board(); + pcb.rules.default_rules.via_diameter = 0.03; + pcb.rules.default_rules.via_drill = 0.01; + pcb.rules.min_drill = 0.2; + + let report = calibrate_rules(&mut pcb); + assert_eq!(pcb.rules.min_drill, 0.2, "declared rule must stand"); + assert!(report.applied.iter().all(|c| c.rule != "minDrill")); + let refused = report + .refused + .iter() + .find(|r| r.rule == "minDrill") + .expect("refusal recorded"); + assert_eq!(refused.floor, FLOOR_DRILL); + } + + #[test] + fn hole_to_hole_calibrates_off_pre_existing_pad_drills_only() { + let mut pcb = test_board(); + pcb.rules.hole_to_hole = 0.5; + // Two THT pads 0.4mm apart centre-to-centre with 0.3mm drills → a + // 0.1mm edge gap the fixture already contains. + with_tht_pad(&mut pcb, "J1", "1", 5.0, 5.0, 0.3); + with_tht_pad(&mut pcb, "J1", "2", 5.4, 5.0, 0.3); + // A via at a tighter spacing must NOT be allowed to justify anything. + pcb.vias.push(vcad_ir::ecad::Via { + position: vcad_ir::Vec2::new(9.0, 9.0), + diameter: 0.2, + drill: 0.1, + start_layer: vcad_ir::ecad::PcbLayer::FCu, + end_layer: vcad_ir::ecad::PcbLayer::BCu, + net: "N1".into(), + source: None, + }); + pcb.vias.push(vcad_ir::ecad::Via { + position: vcad_ir::Vec2::new(9.11, 9.0), + diameter: 0.2, + drill: 0.1, + start_layer: vcad_ir::ecad::PcbLayer::FCu, + end_layer: vcad_ir::ecad::PcbLayer::BCu, + net: "N1".into(), + source: None, + }); + + let report = calibrate_rules(&mut pcb); + let h2h = report + .applied + .iter() + .find(|c| c.rule == "holeToHole") + .expect("holeToHole calibrated"); + assert!( + (h2h.calibrated - 0.1).abs() < 1e-9, + "expected the pad-pair gap, got {}", + h2h.calibrated + ); + assert!( + h2h.justification.contains("J1.1↔J1.2"), + "{}", + h2h.justification + ); + } +} diff --git a/crates/vcad-ecad-fabprep/src/delta.rs b/crates/vcad-ecad-fabprep/src/delta.rs new file mode 100644 index 000000000..33864e32a --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/delta.rs @@ -0,0 +1,646 @@ +//! The DRC delta: what the *routing* is responsible for, separated from what +//! the board arrived with. +//! +//! Absolute zero is not a reachable claim on an imported fixture. The stripped +//! CM5 board — no traces, no vias, nothing but its own land patterns — already +//! scores 980 short/clearance violations, and the human production board +//! scores 16,485 under the same rules. A receipt that reported "1,150 +//! violations" would be indistinguishable from a receipt that reported "1,150 +//! violations, 0 of which we caused", and only the second one is a claim about +//! the router. +//! +//! So every number here is a *pair*: the baseline (same board, all traces and +//! vias stripped, same rules) and the final. Route-attributable is the +//! difference — and how the difference is taken is stated per rule rather than +//! assumed, because the two kinds of rule behave differently: +//! +//! * **Geometric** rules (clearance, widths, drills, rings, edge, hole-to-hole, +//! keepouts) name a fixed pair of objects at a fixed place. Their violations +//! survive stripping unchanged, so the honest difference is a **set** +//! difference: a final violation counts against the router only if it is not +//! literally the same violation the stripped board already had. +//! * **Connectivity** rules (unconnected nets, net islands, unstitched pads) +//! are *supposed* to change when copper is added — stripping the board makes +//! every net unconnected by construction. A set difference would charge the +//! router for the entire final population. These use a **count** difference, +//! saturating at zero, which is the weaker but non-fictional claim: the +//! router left fewer of these than it found. + +use std::collections::{BTreeMap, BTreeSet}; + +use vcad_ecad_pcb::drc::{DrcRuleType, DrcSeverity, DrcViolation}; + +/// How a rule's route-attributable count is derived. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeltaMode { + /// Final violations that are not literally present in the baseline. + /// Used where a violation's identity survives stripping. + SetDifference, + /// `max(0, final − baseline)`. Used where adding copper legitimately + /// rewrites the violation's identity (connectivity rules). + CountDifference, +} + +/// Rules a strip-and-re-route round can actually fix, and therefore the rules +/// the fix loop drives to zero route-attributable. Connectivity rules are not +/// in this set: stripping a net *creates* `UnconnectedNet`, so including them +/// would make the loop chase its own tail, and the verdict ladder already +/// reports unrouted connections as explicit proved-infeasible or unknown +/// certificates rather than hiding them in a DRC count. +pub const ROUTE_FIXABLE: &[DrcRuleType] = &[ + DrcRuleType::Clearance, + DrcRuleType::Short, + DrcRuleType::MinTraceWidth, + DrcRuleType::EdgeClearance, + DrcRuleType::HoleToHole, + DrcRuleType::AnnularRing, + DrcRuleType::MinDrill, + DrcRuleType::Keepout, + DrcRuleType::SameNetBypass, +]; + +/// Every rule name this crate knows, for validating a caller's waiver list. A +/// waiver naming a rule that does not exist is a typo that would silently do +/// nothing, which is the worst possible outcome for a safety valve. +pub const ALL_RULES: &[DrcRuleType] = &[ + DrcRuleType::Clearance, + DrcRuleType::MinTraceWidth, + DrcRuleType::MinDrill, + DrcRuleType::AnnularRing, + DrcRuleType::EdgeClearance, + DrcRuleType::HoleToHole, + DrcRuleType::UnconnectedNet, + DrcRuleType::SilkscreenClearance, + DrcRuleType::CourtyardOverlap, + DrcRuleType::AcidTrap, + DrcRuleType::Keepout, + DrcRuleType::Short, + DrcRuleType::NetIslands, + DrcRuleType::UnstitchedPad, + DrcRuleType::SameNetBypass, +]; + +/// Stable rule label for reports and JSON keys. +/// +/// `DrcRuleType`'s variants are all unit variants, so `Debug` is exactly the +/// variant name — the same spelling the kernel's serde output uses. +pub fn rule_name(rule: &DrcRuleType) -> String { + format!("{rule:?}") +} + +/// How this rule's identity behaves across a strip. +fn delta_mode(rule: &DrcRuleType) -> DeltaMode { + match rule { + DrcRuleType::UnconnectedNet | DrcRuleType::NetIslands | DrcRuleType::UnstitchedPad => { + DeltaMode::CountDifference + } + _ => DeltaMode::SetDifference, + } +} + +/// Net names quoted in a DRC message. +/// +/// The kernel's DRC deliberately spells net attribution into its messages as a +/// quoted token — the same channel the MCP summary buckets by. Two spellings +/// occur and both are read here: +/// +/// * `... net 'A' ... net 'B' ...` — clearance, width, keepout, islands, … +/// * `Short: nets 'A' and 'B' are connected by copper` — the plural form, which +/// a `net '` scan silently misses (there is an `s` between `net` and the +/// quote). Missing it would leave every short unattributed. +/// +/// Returned in first-seen order, deduplicated. +pub fn nets_in_message(message: &str) -> Vec { + let mut out: Vec = Vec::new(); + let bytes = message.as_bytes(); + + let mut push = |name: &str| { + if !name.is_empty() && !out.iter().any(|n| n == name) { + out.push(name.to_string()); + } + }; + + // Plural form: `nets 'A' and 'B'`. + if let Some(rel) = message.find("nets '") { + let mut i = rel + "nets '".len(); + // Two quoted names separated by ` and `. + for _ in 0..2 { + let Some(end_rel) = message[i..].find('\'') else { + break; + }; + let end = i + end_rel; + push(&message[i..end]); + let Some(next_rel) = message[end..].find(" and '") else { + break; + }; + i = end + next_rel + " and '".len(); + } + } + + // Singular form: every `net ''`. `nets '` cannot match this pattern, + // so the two passes never double-read the same token. + let mut i = 0; + while let Some(rel) = message[i..].find("net '") { + let start = i + rel + "net '".len(); + // The token must start a word — `Net-(U1-PAD)` inside a name must not + // be mistaken for an attribution token. + let is_word_start = i + rel == 0 || !bytes[i + rel - 1].is_ascii_alphanumeric(); + match message[start..].find('\'') { + Some(end_rel) => { + let end = start + end_rel; + if is_word_start { + push(&message[start..end]); + } + i = end + 1; + } + None => break, + } + } + out +} + +/// Union-find over net names, used to reason about the fixture's own shorts. +#[derive(Default)] +struct NetGroups { + parent: BTreeMap, +} + +impl NetGroups { + fn find(&mut self, n: &str) -> String { + let Some(p) = self.parent.get(n).cloned() else { + return n.to_string(); + }; + if p == n { + return p; + } + let root = self.find(&p); + self.parent.insert(n.to_string(), root.clone()); + root + } + + fn union(&mut self, a: &str, b: &str) { + let (ra, rb) = (self.find(a), self.find(b)); + if ra != rb { + self.parent.insert(ra, rb); + } + } + + /// The net groups the *fixture* already merged, before any routing. + fn from_shorts(violations: &[DrcViolation]) -> Self { + let mut g = Self::default(); + for v in violations.iter().filter(|v| v.rule == DrcRuleType::Short) { + let nets = nets_in_message(&v.message); + for pair in nets.windows(2) { + g.union(&pair[0], &pair[1]); + } + } + g + } +} + +/// Identity of a violation for set-difference purposes. +/// +/// Shorts do not use this: their identity is the net *group* they belong to, not +/// a place on the board. See [`short_is_route_attributable`]. +fn violation_key(v: &DrcViolation) -> String { + format!( + "{}|{:?}|{:.3},{:.3}|{}", + rule_name(&v.rule), + v.provenance, + v.position.x, + v.position.y, + v.message + ) +} + +/// Whether a short on the finished board is the routing's doing. +/// +/// Shorts propagate, and that makes naive differencing badly wrong on an +/// imported fixture. Suppose the fixture's own overlapping pads already short +/// net X to net A, and elsewhere short net Y to net A. Route net A between its +/// own two pads — a perfectly legal connection — and the two copper blobs merge, +/// so the DRC now also reports X shorted to Y. That pair is new, but the router +/// did nothing wrong: the fault is entirely the fixture's overlapping pads, and +/// the routing was the innocent wire that happened to join them. +/// +/// The sound test is therefore not "is this exact pair new?" but "did the +/// routing merge two groups the fixture had kept apart?". Routing only ever +/// joins copper of a single net, so a merge can only bridge two groups that +/// *already contained that net* — which means the fixture had already shorted +/// into both. A pair whose two nets sit in the same baseline group is +/// fixture-implied; a pair spanning two baseline groups is a real new bridge and +/// is charged. +fn short_is_route_attributable(v: &DrcViolation, baseline_groups: &mut NetGroups) -> bool { + let nets = nets_in_message(&v.message); + if nets.len() < 2 { + // No attribution channel — charge it, because the safe direction for a + // violation we cannot explain is "ours". + return true; + } + let root = baseline_groups.find(&nets[0]); + nets[1..].iter().any(|n| baseline_groups.find(n) != root) +} + +/// Per-rule accounting: what the stripped board had, what the routed board has, +/// and what the difference charges to the routing. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RuleDelta { + /// Rule name (`Clearance`, `NetIslands`, …). + pub rule: String, + /// Violations on the stripped board (same rules, no traces or vias). + pub baseline: usize, + /// Violations on the finished board. + pub final_count: usize, + /// Violations charged to the routing. + pub route_attributable: usize, + /// How `route_attributable` was derived. + pub mode: DeltaMode, + /// True when the rule is one the fix loop drives to zero. + pub route_fixable: bool, + /// True when the caller explicitly waived this rule. An accepted rule is + /// still counted and still reported — it simply does not block the verdict. + pub accepted: bool, +} + +/// A route-attributable violation, carried into the receipt so a +/// non-converging run can name exactly what it could not fix. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Offender { + /// Rule name. + pub rule: String, + /// Severity as reported by the kernel. + pub severity: String, + /// Board position (mm). + pub position: [f64; 2], + /// Kernel message, verbatim. + pub message: String, + /// The rule's required value (mm). Doubles as the search radius when the + /// message names no net and the offender has to be attributed geometrically. + pub required: f64, + /// Nets named in the message, if any. Empty for the rules whose messages + /// carry no net at all (hole-to-hole, annular ring, drill) — those are + /// attributed by position instead, see [`crate::verdict::nets_near`]. + pub nets: Vec, +} + +impl Offender { + fn from(v: &DrcViolation) -> Self { + Self { + rule: rule_name(&v.rule), + severity: match v.severity { + DrcSeverity::Error => "error".into(), + DrcSeverity::Warning => "warning".into(), + }, + position: [v.position.x, v.position.y], + message: v.message.clone(), + required: v.required, + nets: nets_in_message(&v.message), + } + } +} + +/// The full delta between a stripped-board baseline and a finished board. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DrcDelta { + /// Per-rule rows, rule-name ordered. + pub rules: Vec, + /// Total violations on the stripped board. + pub baseline_total: usize, + /// Total violations on the finished board. + pub final_total: usize, + /// Total charged to the routing, across every rule. + pub route_attributable_total: usize, + /// Charged to the routing, restricted to [`ROUTE_FIXABLE`] minus any rule + /// the caller waived — the number the fix loop is trying to zero and the one + /// the convergence verdict reads. + pub route_attributable_fixable: usize, + /// Charged to the routing but explicitly waived by the caller. Never zero + /// silently: a waiver that hides violations has to show how many. + pub route_attributable_accepted: usize, + /// The route-attributable violations the loop must clear — [`ROUTE_FIXABLE`] + /// minus anything the caller waived. + pub offenders: Vec, + /// Route-attributable violations in waived rules. Kept separate from + /// `offenders` so the loop does not chase them, and kept at all so the + /// report can label them as *waived* rather than silently filing them under + /// the fixture's baseline — which would be the exact misattribution this + /// whole delta exists to prevent. + pub waived: Vec, +} + +impl DrcDelta { + /// Compute the delta between `baseline` (stripped board) and `final_`. + /// + /// `accepted` names rules the caller has explicitly waived — they keep their + /// counts in the table and gain an `accepted` flag, but stop blocking the + /// verdict. The CM5 campaign did exactly this in prose for the 69 fab-legal + /// 0.08 mm neckdowns on its SI-class nets; making it a parameter with a + /// receipt entry is the same decision, only auditable. + pub fn compute( + baseline: &[DrcViolation], + final_: &[DrcViolation], + accepted: &BTreeSet, + ) -> Self { + let fixable: BTreeSet = ROUTE_FIXABLE + .iter() + .map(rule_name) + .filter(|r| !accepted.contains(r)) + .collect(); + + // (baseline count, final count, mode) per rule name. + let mut counts: BTreeMap = BTreeMap::new(); + for v in baseline { + counts + .entry(rule_name(&v.rule)) + .or_insert((0, 0, delta_mode(&v.rule))) + .0 += 1; + } + for v in final_ { + counts + .entry(rule_name(&v.rule)) + .or_insert((0, 0, delta_mode(&v.rule))) + .1 += 1; + } + + // A baseline key can legitimately occur more than once (two identical + // messages at the same rounded position); consume one baseline + // occurrence per matching final violation so a genuine duplicate the + // router *added* still shows up. + let mut remaining: BTreeMap = BTreeMap::new(); + for v in baseline { + *remaining.entry(violation_key(v)).or_default() += 1; + } + + let mut baseline_groups = NetGroups::from_shorts(baseline); + let mut attributable: Vec<&DrcViolation> = Vec::new(); + for v in final_ { + if delta_mode(&v.rule) != DeltaMode::SetDifference { + continue; + } + if v.rule == DrcRuleType::Short { + if short_is_route_attributable(v, &mut baseline_groups) { + attributable.push(v); + } + continue; + } + match remaining.get_mut(&violation_key(v)) { + Some(n) if *n > 0 => *n -= 1, + _ => attributable.push(v), + } + } + + let mut set_attr: BTreeMap = BTreeMap::new(); + for v in &attributable { + *set_attr.entry(rule_name(&v.rule)).or_default() += 1; + } + + let rules: Vec = counts + .iter() + .map(|(name, (b, f, mode))| { + let route_attributable = match mode { + DeltaMode::SetDifference => set_attr.get(name).copied().unwrap_or(0), + DeltaMode::CountDifference => f.saturating_sub(*b), + }; + RuleDelta { + rule: name.clone(), + baseline: *b, + final_count: *f, + route_attributable, + mode: *mode, + route_fixable: fixable.contains(name), + accepted: accepted.contains(name), + } + }) + .collect(); + + let offenders: Vec = attributable + .iter() + .copied() + .filter(|v| fixable.contains(&rule_name(&v.rule))) + .map(Offender::from) + .collect(); + let waived: Vec = attributable + .iter() + .copied() + .filter(|v| accepted.contains(&rule_name(&v.rule))) + .map(Offender::from) + .collect(); + + Self { + baseline_total: baseline.len(), + final_total: final_.len(), + route_attributable_total: rules.iter().map(|r| r.route_attributable).sum(), + route_attributable_fixable: rules + .iter() + .filter(|r| r.route_fixable) + .map(|r| r.route_attributable) + .sum(), + route_attributable_accepted: rules + .iter() + .filter(|r| r.accepted) + .map(|r| r.route_attributable) + .sum(), + offenders, + waived, + rules, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vcad_ecad_pcb::drc::DrcProvenance; + use vcad_ir::Vec2; + + fn v(rule: DrcRuleType, x: f64, msg: &str) -> DrcViolation { + DrcViolation { + rule, + severity: DrcSeverity::Error, + position: Vec2::new(x, 0.0), + message: msg.to_string(), + actual: 0.0, + required: 0.1, + provenance: DrcProvenance::Routing, + generated: false, + } + } + + #[test] + fn nets_parse_out_of_kernel_messages() { + assert_eq!( + nets_in_message("Clearance violation: trace net 'GND' to net '+5V': 0.01mm < 0.08mm"), + vec!["GND", "+5V"], + "both sides of a clearance pair are attributable" + ); + assert_eq!( + nets_in_message( + "Clearance violation: pad C1.1 net 'VCC' to pad J1.2 net 'Net-(U1-PAD)': 0.0mm" + ), + vec!["VCC", "Net-(U1-PAD)"] + ); + assert!(nets_in_message("Via annular ring 0.030mm < 0.045mm").is_empty()); + assert_eq!( + nets_in_message("Short: nets 'GND' and '+3V3' are connected by copper"), + vec!["GND", "+3V3"], + "the plural `nets 'A' and 'B'` spelling is the only attribution a short carries" + ); + } + + #[test] + fn an_identical_geometric_violation_is_not_charged_to_the_router() { + let base = vec![v(DrcRuleType::Clearance, 1.0, "pad overlap")]; + let fin = vec![ + v(DrcRuleType::Clearance, 1.0, "pad overlap"), + v(DrcRuleType::Clearance, 2.0, "trace net 'A' to net 'B'"), + ]; + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + assert_eq!(d.baseline_total, 1); + assert_eq!(d.final_total, 2); + assert_eq!(d.route_attributable_fixable, 1); + assert_eq!(d.offenders.len(), 1); + assert_eq!(d.offenders[0].nets, vec!["A", "B"]); + } + + #[test] + fn a_duplicated_baseline_violation_still_counts_once_against_the_router() { + let base = vec![v(DrcRuleType::Clearance, 1.0, "pad overlap")]; + let fin = vec![ + v(DrcRuleType::Clearance, 1.0, "pad overlap"), + v(DrcRuleType::Clearance, 1.0, "pad overlap"), + ]; + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + assert_eq!(d.route_attributable_fixable, 1); + } + + #[test] + fn connectivity_rules_use_a_count_difference_and_saturate() { + let base = vec![ + v(DrcRuleType::NetIslands, 1.0, "Disjoint net 'A': 3 islands"), + v(DrcRuleType::NetIslands, 2.0, "Disjoint net 'B': 2 islands"), + ]; + let fin = vec![v( + DrcRuleType::NetIslands, + 9.0, + "Disjoint net 'C': 2 islands", + )]; + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + let row = d.rules.iter().find(|r| r.rule == "NetIslands").unwrap(); + assert_eq!(row.mode, DeltaMode::CountDifference); + assert_eq!(row.baseline, 2); + assert_eq!(row.final_count, 1); + assert_eq!( + row.route_attributable, 0, + "the router repaired one island; that is never negative credit" + ); + } + + #[test] + fn a_short_keeps_its_identity_when_the_contact_point_moves() { + let base = vec![v( + DrcRuleType::Short, + 1.0, + "Short: nets 'A' and 'B' are connected by copper", + )]; + let fin = vec![v( + DrcRuleType::Short, + 7.5, + "Short: nets 'B' and 'A' are connected by copper", + )]; + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + assert_eq!( + d.route_attributable_fixable, 0, + "same net pair, different contact point — the fixture's short, not ours" + ); + } + + /// The fixture shorts X–A and Y–A through its own overlapping pads. Routing + /// net A between its own pads merges the two blobs, so X–Y is now reported + /// too. The router is not answerable for a pair the fixture's pad overlaps + /// had already implied. + #[test] + fn a_short_implied_by_two_fixture_shorts_is_not_charged_to_the_router() { + let base = vec![ + v( + DrcRuleType::Short, + 1.0, + "Short: nets 'X' and 'A' are connected by copper", + ), + v( + DrcRuleType::Short, + 2.0, + "Short: nets 'Y' and 'A' are connected by copper", + ), + ]; + let mut fin = base.clone(); + fin.push(v( + DrcRuleType::Short, + 3.0, + "Short: nets 'X' and 'Y' are connected by copper", + )); + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + assert_eq!( + d.route_attributable_fixable, 0, + "X and Y sit in the same baseline short group — the fixture merged them" + ); + } + + /// A genuinely new bridge between two nets the fixture kept apart is + /// charged, and named. + #[test] + fn a_short_bridging_two_baseline_groups_is_charged() { + let base = vec![v( + DrcRuleType::Short, + 1.0, + "Short: nets 'X' and 'A' are connected by copper", + )]; + let fin = vec![ + base[0].clone(), + v( + DrcRuleType::Short, + 9.0, + "Short: nets 'A' and 'Z' are connected by copper", + ), + ]; + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + assert_eq!(d.route_attributable_fixable, 1); + assert_eq!(d.offenders[0].nets, vec!["A", "Z"]); + } + + /// A waived violation is not the fixture's fault and must never be filed as + /// such — that misattribution is the exact thing this delta exists to stop. + #[test] + fn a_waived_violation_is_listed_as_waived_not_as_fixture_baseline() { + let base: Vec = vec![]; + let fin = vec![v( + DrcRuleType::MinTraceWidth, + 1.0, + "Trace width 0.050mm below minimum 0.200mm for net 'A'", + )]; + let accepted: BTreeSet = ["MinTraceWidth".to_string()].into_iter().collect(); + let d = DrcDelta::compute(&base, &fin, &accepted); + assert_eq!(d.route_attributable_fixable, 0, "waived: does not block"); + assert_eq!(d.route_attributable_accepted, 1); + assert!( + d.offenders.is_empty(), + "the loop must not chase a waived rule" + ); + assert_eq!(d.waived.len(), 1, "but it must still be attributed to us"); + assert_eq!(d.waived[0].nets, vec!["A"]); + } + + #[test] + fn non_fixable_rules_stay_out_of_the_convergence_number() { + let base: Vec = vec![]; + let fin = vec![v( + DrcRuleType::UnstitchedPad, + 1.0, + "Unstitched pad U1.1 on plane net 'GND'", + )]; + let d = DrcDelta::compute(&base, &fin, &BTreeSet::new()); + assert_eq!(d.route_attributable_total, 1); + assert_eq!(d.route_attributable_fixable, 0); + assert!(d.offenders.is_empty()); + } +} diff --git a/crates/vcad-ecad-fabprep/src/lib.rs b/crates/vcad-ecad-fabprep/src/lib.rs new file mode 100644 index 000000000..10b4e6009 --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/lib.rs @@ -0,0 +1,894 @@ +#![warn(missing_docs)] +//! Fab preparation as one reproducible command. +//! +//! Taking a routed board to a manufacturable Gerber package used to be a hand-run +//! sequence of example binaries: certify the remaining connections, calibrate the +//! rules, census the offending nets, strip them, re-route, repeat, prune the dead +//! copper, export. This crate is that sequence, with the thing that made it +//! trustworthy — the DRC delta — promoted from a note in a chat log to the +//! command's receipt. +//! +//! # The receipt is the point +//! +//! On an imported fixture, "zero DRC violations" is not an achievable claim and +//! anyone who reports it is reporting something else. The CM5 board with every +//! trace and via stripped out already scores 980 short/clearance violations +//! against its own land patterns, and the *human production board* scores 16,485 +//! under the same rules. So [`FabPrepReport`] never reports one number. It +//! reports the pair — baseline (same board, no routing) and final — and the +//! difference between them, which is the only part the router is answerable for. +//! See [`delta`] for how the difference is taken, per rule. +//! +//! # Rule calibration is opt-in and logged +//! +//! Imported boards often declare global minima that forbid their own via +//! classes. Relaxing a DRC rule to make a board pass is a serious footgun, so +//! calibration never runs unless asked for, only ever relaxes a rule to the +//! point where the board's *own given* geometry stops being illegal, is floored +//! at the tightest process this codebase will name, and records its derivation +//! sentence in the receipt. See [`calibrate`]. +//! +//! # Fail closed +//! +//! If the fix loop does not drive route-attributable violations to zero, the run +//! reports `converged: false` with the remaining offenders, and +//! [`package::write_fab_package`] refuses to write fabrication files. The report +//! and the board are still written, so the next run can pick up where this one +//! stopped. +//! +//! ```no_run +//! use vcad_ecad_fabprep::{run_fab_prep, FabPrepOptions}; +//! # fn main() -> Result<(), Box> { +//! let mut pcb: vcad_ir::ecad::Pcb = serde_json::from_str(&std::fs::read_to_string("routed.pcb.json")?)?; +//! let out = run_fab_prep(&mut pcb, &FabPrepOptions { calibrate_rules: true, ..Default::default() }); +//! // Refuses (and writes only the receipt) unless `out.report.converged`. +//! # #[cfg(feature = "package")] +//! vcad_ecad_fabprep::package::write_fab_package("out/".as_ref(), &pcb, &out, None)?; +//! # Ok(()) } +//! ``` + +pub mod calibrate; +pub mod delta; +#[cfg(feature = "package")] +pub mod package; +pub mod render; +pub mod verdict; + +#[cfg(test)] +mod test_support; + +use std::collections::BTreeSet; + +use vcad_ecad_pcb::drc::check_drc; +use vcad_ir::ecad::Pcb; + +pub use calibrate::{CalibrationReport, RefusedCalibration, RuleCalibration}; +pub use delta::{DeltaMode, DrcDelta, Offender, RuleDelta, ROUTE_FIXABLE}; +pub use verdict::{VerdictOptions, VerdictSummary}; + +/// Knobs for one fab-prep run. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FabPrepOptions { + /// Derive and apply design-rule calibration from the board's own declared + /// via classes and pre-existing holes. **Off by default** — a silent rule + /// relaxation is how a board that cannot be built passes its own DRC. + pub calibrate_rules: bool, + /// Run the verdict ladder once before the fix loop, to route or certify + /// connections the board arrived without. + pub route_remaining: bool, + /// Maximum strip-and-re-route rounds. The CM5 board converged in 5. + pub max_rounds: usize, + /// Search knobs handed to the verdict ladder. + pub verdict: VerdictOptions, + /// Remove copper islands that touch no pad or pour of their net before the + /// final DRC. + pub prune_dangling: bool, + /// Rule names the caller explicitly waives (`MinTraceWidth`, …). + /// + /// A waived rule keeps its counts in the receipt and gains an `accepted` + /// flag; it simply stops blocking the verdict. This exists because real fab + /// packages ship with real, named exceptions — the CM5 campaign accepted 69 + /// fab-legal 0.08 mm neckdowns on its SI-class nets. The difference between + /// that and a footgun is whether the exception is written down, so an + /// unrecognised name here is an error rather than a silent no-op. + #[serde(default)] + pub accept_rules: Vec, +} + +impl Default for FabPrepOptions { + fn default() -> Self { + Self { + calibrate_rules: false, + route_remaining: true, + max_rounds: 8, + verdict: VerdictOptions::default(), + prune_dangling: true, + accept_rules: Vec::new(), + } + } +} + +/// One iteration of the strip-and-re-route fix loop. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RoundSummary { + /// 1-based round number. + pub round: usize, + /// Route-attributable violations in [`ROUTE_FIXABLE`] rules at the start of + /// this round — the number the round is trying to reduce. + pub attributable_before: usize, + /// Nets stripped and handed back to the router. + pub offending_nets: Vec, + /// Traces removed by the strip. + pub stripped_traces: usize, + /// Vias removed by the strip. + pub stripped_vias: usize, + /// What the re-route concluded. + pub verdict: VerdictSummary, +} + +/// Board-shape facts worth stating next to the verdict. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BoardStats { + /// Copper layers in the stackup. + pub copper_layers: usize, + /// Distinct nets named by pads. + pub nets: usize, + /// Pads across all footprints. + pub pads: usize, + /// Placed footprints. + pub footprints: usize, + /// Trace segments. + pub traces: usize, + /// Vias. + pub vias: usize, + /// Filled zones. + pub zones: usize, +} + +impl BoardStats { + /// Measure a board. + pub fn of(pcb: &Pcb) -> Self { + let nets: BTreeSet<&str> = pcb + .footprints + .iter() + .flat_map(|f| f.pads.iter()) + .filter_map(|p| p.net.as_deref()) + .filter(|n| !n.is_empty()) + .collect(); + Self { + copper_layers: pcb + .stackup + .layers + .iter() + .filter(|l| l.layer.is_copper()) + .count(), + nets: nets.len(), + pads: pcb.footprints.iter().map(|f| f.pads.len()).sum(), + footprints: pcb.footprints.len(), + traces: pcb.traces.len(), + vias: pcb.vias.len(), + zones: pcb.zones.len(), + } + } +} + +/// Unconnected-net and net-island violations, counted before and after. +/// +/// The guard against the one cheat this pipeline structurally has available to +/// it. Every geometric violation the fix loop chases can be made to disappear by +/// deleting the copper that causes it, and a board with no traces at all scores +/// perfectly on clearance, width, hole-to-hole and shorts. Watching connectivity +/// across the run is what turns "the violations went away" back into "the board +/// got better". +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Connectivity { + /// Unconnected-net + net-island violations on the board as given. + pub on_arrival: usize, + /// The same count on the finished board. + pub on_completion: usize, +} + +impl Connectivity { + /// How much worse the finished board is. Non-zero blocks convergence. + pub fn regression(&self) -> usize { + self.on_completion.saturating_sub(self.on_arrival) + } +} + +/// Unconnected-net and net-island violations in a DRC result — "the netlist is +/// not realized", as distinct from "the copper breaks a geometric rule". +fn connectivity_count(violations: &[vcad_ecad_pcb::drc::DrcViolation]) -> usize { + use vcad_ecad_pcb::drc::DrcRuleType; + violations + .iter() + .filter(|v| { + matches!( + v.rule, + DrcRuleType::UnconnectedNet | DrcRuleType::NetIslands + ) + }) + .count() +} + +/// The receipt: everything a reader needs to audit the run without rerunning it. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FabPrepReport { + /// True when route-attributable violations in [`ROUTE_FIXABLE`] rules + /// reached zero. False means fail-closed: no fab package is written. + pub converged: bool, + /// Why the loop stopped short, when it did. + pub blocker: Option, + /// Whether calibration was asked for at all. + pub calibration_requested: bool, + /// What calibration changed and what it refused. + pub calibration: CalibrationReport, + /// The up-front verdict pass, when it ran. + pub initial_verdict: Option, + /// The fix loop, round by round. + pub rounds: Vec, + /// Dangling traces removed by the prune. + pub pruned_traces: usize, + /// Dangling vias removed by the prune. + pub pruned_vias: usize, + /// How much of the netlist was realized on arrival vs on completion. + pub connectivity: Connectivity, + /// Rules the caller waived, resolved to canonical names. Their violations + /// are still counted and still listed — they just do not block the verdict. + pub accepted_rules: Vec, + /// Baseline vs final, per rule. + pub delta: DrcDelta, + /// The finished board's shape. + pub board: BoardStats, +} + +impl FabPrepReport { + /// The claim this run supports, in one sentence. + pub fn headline(&self) -> String { + if self.converged { + let waived = match self.delta.route_attributable_accepted { + 0 => String::new(), + n => format!(", {n} waived under {}", self.accepted_rules.join("+")), + }; + format!( + "zero route-attributable violations{waived} ({} on the finished board, {} on the \ + same board stripped of all routing)", + self.delta.final_total, self.delta.baseline_total + ) + } else { + format!( + "NOT CONVERGED — {} route-attributable violation(s) remain ({})", + self.delta.route_attributable_fixable, + self.blocker.as_deref().unwrap_or("round limit reached") + ) + } + } +} + +/// What a run produces: the receipt, and the DRC evidence it was derived from. +/// +/// The violations travel with the report because a full board DRC is one of the +/// most expensive things this pipeline does — the renderers and the package +/// writer need them, and recomputing would be a silent tax on every caller. +#[derive(Debug, Clone)] +pub struct FabPrepOutcome { + /// The receipt. + pub report: FabPrepReport, + /// Every violation on the finished board, under the rules the report names. + pub violations: Vec, +} + +/// A run that refuses to start, expressed as an ordinary non-converging +/// outcome so a caller that already handles "did not converge" needs no second +/// error path — and so the refusal lands in the same receipt everything else +/// does. +fn refused(pcb: &Pcb, opts: &FabPrepOptions, why: String) -> FabPrepOutcome { + FabPrepOutcome { + report: FabPrepReport { + converged: false, + blocker: Some(why), + calibration_requested: opts.calibrate_rules, + calibration: CalibrationReport::default(), + initial_verdict: None, + rounds: Vec::new(), + pruned_traces: 0, + pruned_vias: 0, + connectivity: Connectivity::default(), + accepted_rules: Vec::new(), + delta: DrcDelta::compute(&[], &[], &BTreeSet::new()), + board: BoardStats::of(pcb), + }, + violations: Vec::new(), + } +} + +/// Run the whole fab-prep pipeline against `pcb`, mutating it in place. +/// +/// Never panics on a difficult board: a run that cannot converge returns a +/// report with `converged: false` and the offenders named, and it is the +/// caller's job (see [`package::write_fab_package`]) not to ship it. +pub fn run_fab_prep(pcb: &mut Pcb, opts: &FabPrepOptions) -> FabPrepOutcome { + // 0. Waivers. A name that matches no rule is refused outright: a typo in a + // safety valve that silently does nothing is worse than no valve. + let known: BTreeSet = delta::ALL_RULES.iter().map(delta::rule_name).collect(); + let (accepted, unknown): (BTreeSet, Vec) = { + let mut ok = BTreeSet::new(); + let mut bad = Vec::new(); + for r in &opts.accept_rules { + match known.iter().find(|k| k.eq_ignore_ascii_case(r)) { + Some(k) => { + ok.insert(k.clone()); + } + None => bad.push(r.clone()), + } + } + (ok, bad) + }; + + if !unknown.is_empty() { + return refused( + pcb, + opts, + format!( + "unrecognised rule name(s) in the waiver list: {}. Valid names: {}", + unknown.join(", "), + known.iter().cloned().collect::>().join(", ") + ), + ); + } + + // 1. Rule calibration, before anything is measured — the baseline and the + // final must be judged under identical rules or the delta is fiction. + let calibration = if opts.calibrate_rules { + calibrate::calibrate_rules(pcb) + } else { + CalibrationReport::default() + }; + + // 2. Baseline: the same board, same rules, with every trace and via + // removed. This is the floor the fixture arrives with. + let baseline = { + let mut stripped = pcb.clone(); + stripped.traces.clear(); + stripped.trace_arcs.clear(); + stripped.vias.clear(); + check_drc(&stripped) + }; + log::info!( + "fab-prep: fixture baseline = {} violations (stripped board)", + baseline.len() + ); + + // 2b. Connectivity as the board arrived. The fix loop strips nets to clear + // violations, which means it has a trivially "successful" move available + // to it: delete the copper and the geometric violation goes with it. The + // resulting board is DRC-clean and electrically useless. Recording what + // the netlist realized on arrival lets the verdict refuse that trade. + let entry_connectivity = connectivity_count(&check_drc(pcb)); + + // 3. Route or certify whatever the board arrived unrouted. + let initial_verdict = opts.route_remaining.then(|| { + let v = verdict::route_remaining(pcb, opts.verdict); + log::info!( + "fab-prep: verdict ladder routed {} / proved-infeasible {} / unknown {}", + v.routed, + v.proved_infeasible, + v.unknown + ); + v + }); + + // 4. The fix loop: census the route-attributable offenders, strip their + // nets, hand them back to the (session-probed) ladder, re-check. + let mut rounds: Vec = Vec::new(); + let mut blocker: Option = None; + let mut converged = false; + let mut previous: Option<(BTreeSet, usize)> = None; + // A round strips whole nets and hands them back to the router, so a round + // whose re-route fails leaves the board sparser than it found it. Left + // unguarded the loop can wander downhill for its whole round budget and + // return a board materially worse than the one it was given. Keep the best + // board seen and restore it if the loop never converges: fab-prep may fail + // to improve a board, but it must never hand back a worse one. + let mut best: Option<(usize, Pcb)> = None; + let mut non_improving = 0usize; + /// Consecutive rounds allowed to not improve before the loop gives up. One + /// bad round can be a productive intermediate; two in a row is wandering. + const MAX_NON_IMPROVING: usize = 2; + + for round in 0..=opts.max_rounds { + let now = check_drc(pcb); + let regression = connectivity_count(&now).saturating_sub(entry_connectivity); + let delta = DrcDelta::compute(&baseline, &now, &accepted); + let attributable = delta.route_attributable_fixable; + if attributable == 0 && regression == 0 { + converged = true; + best = None; + break; + } + // Score a candidate board on both axes at once. Scoring on + // `attributable` alone would rank "stripped the net, never re-routed + // it" as the best board on offer. + let score = attributable + regression; + match &best { + Some((n, _)) if *n <= score => non_improving += 1, + _ => { + non_improving = 0; + best = Some((score, pcb.clone())); + } + } + if attributable == 0 { + // Geometrically clean, but the loop got there by removing copper. + blocker = Some(format!( + "the board is geometrically clean but {regression} more net(s) are unconnected or \ + islanded than when it arrived — the loop cleared violations by removing copper it \ + could not re-route, which is not a fix" + )); + break; + } + if non_improving >= MAX_NON_IMPROVING { + blocker = Some(format!( + "the fix loop stopped improving — {MAX_NON_IMPROVING} consecutive rounds failed to \ + beat the best board seen" + )); + break; + } + if round == opts.max_rounds { + // Blockers say why the loop STOPPED and never restate the count — + // `headline()` owns that number, and a blocker carrying a stale + // count (the prune below can still change it) reads as a + // contradiction in the receipt. + blocker = Some(format!("the round limit ({}) was reached", opts.max_rounds)); + break; + } + + // Only nets that actually own copper can be stripped and re-routed. An + // offender naming no such net is one this loop structurally cannot fix + // — say so rather than spinning. + let with_copper = verdict::nets_with_copper(pcb); + let offending: BTreeSet = delta + .offenders + .iter() + .flat_map(|o| { + if o.nets.is_empty() { + // Hole-to-hole, annular ring and drill violations report a + // place, not a net. Read the nets off the copper standing + // there — otherwise the loop calls ordinary re-routing work + // unreachable. + verdict::nets_near(pcb, o.position, o.required.max(0.5)) + .into_iter() + .collect::>() + } else { + o.nets.clone() + } + }) + .filter(|n| with_copper.contains(n)) + .collect(); + if offending.is_empty() { + blocker = Some( + "the remaining offenders name no net carrying board-level copper — the \ + strip-and-re-route loop cannot reach them" + .to_string(), + ); + break; + } + // Oscillation guard: the same net set failing to reduce the count means + // the ladder is returning the same copper. Stop and report rather than + // burning the remaining rounds on it. + if let Some((prev_nets, prev_count)) = &previous { + if prev_nets == &offending && attributable >= *prev_count { + blocker = Some(format!( + "the fix loop stalled: round {round} re-stripped the same {} net(s) without \ + reducing the count", + offending.len() + )); + break; + } + } + previous = Some((offending.clone(), attributable)); + + let (stripped_traces, _arcs, stripped_vias) = verdict::strip_nets(pcb, &offending); + log::info!( + "fab-prep round {}: {attributable} attributable, stripped {stripped_traces} traces / \ + {stripped_vias} vias across {} nets", + round + 1, + offending.len() + ); + let verdict = verdict::route_remaining(pcb, opts.verdict); + rounds.push(RoundSummary { + round: round + 1, + attributable_before: attributable, + offending_nets: offending.into_iter().collect(), + stripped_traces, + stripped_vias, + verdict, + }); + } + + // 4b. The loop ended without converging: hand back the best board it saw + // rather than whatever the last (failed) round happened to leave. + let mut restored_best = false; + if !converged { + if let Some((best_score, board)) = best { + let now = check_drc(pcb); + let score = DrcDelta::compute(&baseline, &now, &accepted).route_attributable_fixable + + connectivity_count(&now).saturating_sub(entry_connectivity); + if best_score < score { + log::info!( + "fab-prep: restoring the best board seen (score {best_score} vs {score})" + ); + *pcb = board; + restored_best = true; + } + } + } + + // 5. Prune copper that reaches no pad or pour of its net. Removing copper + // can only remove geometric violations, so this cannot invalidate a + // convergence reached above — but the final DRC below is what the + // receipt reports either way. + let (pruned_traces, pruned_vias) = if opts.prune_dangling { + vcad_ecad_pcb::drc::prune_dangling_copper(pcb) + } else { + (0, 0) + }; + + // 6. Final DRC and the delta that is the receipt. + let violations = check_drc(pcb); + let delta = DrcDelta::compute(&baseline, &violations, &accepted); + let connectivity = Connectivity { + on_arrival: entry_connectivity, + on_completion: connectivity_count(&violations), + }; + // The prune runs after the loop's last check, so re-derive convergence from + // the numbers actually being reported rather than trusting the loop's flag. + let converged = + converged && delta.route_attributable_fixable == 0 && connectivity.regression() == 0; + if converged { + blocker = None; + } else if blocker.is_none() { + blocker = Some(if connectivity.regression() > 0 { + format!( + "{} more net(s) are unconnected or islanded than when the board arrived", + connectivity.regression() + ) + } else { + "violations appeared in the post-prune check".to_string() + }); + } + if restored_best { + if let Some(why) = &mut blocker { + why.push_str(" (the best board the loop saw was restored)"); + } + } + + FabPrepOutcome { + report: FabPrepReport { + converged, + blocker, + calibration_requested: opts.calibrate_rules, + calibration, + initial_verdict, + rounds, + pruned_traces, + pruned_vias, + connectivity, + accepted_rules: accepted.iter().cloned().collect(), + delta, + board: BoardStats::of(pcb), + }, + violations, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_board, with_smd_pad, with_trace}; + + /// A board whose routing is legal: the loop has nothing to do and reports + /// convergence on round zero. + #[test] + fn a_clean_board_converges_without_stripping_anything() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + ..Default::default() + }, + ) + .report; + assert!(report.converged, "{:?}", report.blocker); + assert!(report.rounds.is_empty()); + assert_eq!(report.delta.route_attributable_fixable, 0); + assert!(report.headline().contains("zero route-attributable")); + } + + /// Two nets' traces laid across each other: a genuine, route-attributable + /// short the fixture baseline does not contain. With re-routing disabled + /// the loop cannot fix it, so it must fail closed — with the offender named + /// — rather than declaring the board ready. + #[test] + fn an_unfixable_route_violation_fails_closed_and_names_the_offender() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R2", "1", 5.0, 0.5, "B"); + with_smd_pad(&mut pcb, "R2", "2", 5.0, 4.0, "B"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + with_trace(&mut pcb, "B", 5.0, 0.5, 5.0, 4.0); + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + // One round is enough to prove the loop tried and stopped. + max_rounds: 1, + ..Default::default() + }, + ) + .report; + assert!(!report.converged, "a crossing short must never pass"); + assert!(report.blocker.is_some()); + assert!(report.delta.route_attributable_fixable > 0); + let nets: BTreeSet<&str> = report + .delta + .offenders + .iter() + .flat_map(|o| o.nets.iter().map(|s| s.as_str())) + .collect(); + assert!(nets.contains("A") && nets.contains("B"), "{nets:?}"); + assert!(report.headline().contains("NOT CONVERGED")); + } + + /// The loop must actually run — a strippable offender produces a round with + /// the offending nets recorded. + #[test] + fn the_fix_loop_records_the_round_it_ran() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R2", "1", 5.0, 0.5, "B"); + with_smd_pad(&mut pcb, "R2", "2", 5.0, 4.0, "B"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + with_trace(&mut pcb, "B", 5.0, 0.5, 5.0, 4.0); + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + max_rounds: 2, + ..Default::default() + }, + ) + .report; + assert!(!report.rounds.is_empty(), "the loop must have run a round"); + let r = &report.rounds[0]; + assert_eq!(r.round, 1); + assert!(r.attributable_before > 0); + assert!(r.stripped_traces > 0, "the offending nets must be stripped"); + assert!(r.offending_nets.iter().any(|n| n == "A" || n == "B")); + } + + /// Baseline violations the board arrived with are never charged to the + /// router, and the receipt shows both numbers so nobody mistakes one for + /// the other. + #[test] + fn fixture_baseline_violations_are_reported_but_not_charged() { + let mut pcb = test_board(); + // Two pads of different nets touching: a pad-artifact short that + // survives stripping, exactly like the CM5 fixture's own floor. + with_smd_pad(&mut pcb, "U1", "1", 3.0, 3.0, "A"); + with_smd_pad(&mut pcb, "U1", "2", 3.05, 3.0, "B"); + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + ..Default::default() + }, + ) + .report; + assert!( + report.delta.baseline_total > 0, + "the fixture must score against itself" + ); + assert_eq!(report.delta.route_attributable_fixable, 0); + assert!(report.converged); + assert!( + report.headline().contains("stripped of all routing"), + "the headline must state both numbers: {}", + report.headline() + ); + } + + /// A round strips whole nets and can fail to re-route them, which leaves the + /// board sparser than it started. However badly the loop wanders, the board + /// it hands back must never be worse than the best one it saw. + #[test] + fn a_wandering_loop_never_returns_a_worse_board_than_it_found() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R2", "1", 5.0, 0.5, "B"); + with_smd_pad(&mut pcb, "R2", "2", 5.0, 4.0, "B"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + with_trace(&mut pcb, "B", 5.0, 0.5, 5.0, 4.0); + // Copper that is nowhere near the crossing short and has no business + // being touched by the loop. + with_smd_pad(&mut pcb, "R3", "1", 1.0, 9.0, "C"); + with_smd_pad(&mut pcb, "R3", "2", 9.0, 9.0, "C"); + with_trace(&mut pcb, "C", 1.0, 9.0, 9.0, 9.0); + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + max_rounds: 6, + // A budget too small to re-route anything: every round strips + // and fails, which is exactly the downhill case. + verdict: VerdictOptions { + budget: 1, + max_cluster: 6, + }, + ..Default::default() + }, + ) + .report; + + assert!(!report.converged, "an unroutable short must fail closed"); + assert!( + report.connectivity.regression() > 0 + && report + .blocker + .as_deref() + .is_some_and(|b| b.contains("unconnected")), + "clearing violations by deleting copper must be named as the reason, not passed: \ + {:?} / {:?}", + report.connectivity, + report.blocker + ); + assert!( + pcb.traces.iter().any(|t| t.net == "C"), + "untouched copper must survive: {:?}", + pcb.traces.iter().map(|t| &t.net).collect::>() + ); + assert!( + report.delta.route_attributable_fixable + <= report + .rounds + .iter() + .map(|r| r.attributable_before) + .max() + .unwrap_or(usize::MAX), + "the returned board must be no worse than the best round saw" + ); + } + + /// A waiver lets a run converge past a rule the caller has decided to + /// accept — but it must never make the violations invisible. + #[test] + fn a_waiver_unblocks_the_verdict_without_hiding_the_count() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + // A trace under the class minimum: a MinTraceWidth violation the loop + // cannot fix without re-routing. + pcb.traces.push(vcad_ir::ecad::Trace { + start: vcad_ir::Vec2::new(2.0, 2.0), + end: vcad_ir::Vec2::new(8.0, 2.0), + width: 0.05, + layer: vcad_ir::ecad::PcbLayer::FCu, + net: "A".into(), + source: None, + }); + + let base = FabPrepOptions { + route_remaining: false, + prune_dangling: false, + max_rounds: 0, + ..Default::default() + }; + let unwaived = run_fab_prep(&mut pcb.clone(), &base).report; + assert!( + !unwaived.converged, + "the width violation must block by default" + ); + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + accept_rules: vec!["MinTraceWidth".into()], + ..base + }, + ) + .report; + assert!(report.converged, "{:?}", report.blocker); + assert_eq!(report.accepted_rules, vec!["MinTraceWidth"]); + assert!( + report.delta.route_attributable_accepted > 0, + "the waived violations must still be counted" + ); + let row = report + .delta + .rules + .iter() + .find(|r| r.rule == "MinTraceWidth") + .expect("still in the table"); + assert!(row.accepted && row.route_attributable > 0); + let notes = render::fab_notes(&report); + assert!(notes.contains("**With waivers.**"), "{notes}"); + } + + /// A typo in a waiver would silently accept nothing, which is the most + /// dangerous way for a safety valve to fail. Refuse the run instead. + #[test] + fn an_unrecognised_waiver_name_refuses_the_run() { + let mut pcb = test_board(); + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + accept_rules: vec!["MinTraceWidht".into()], + ..Default::default() + }, + ) + .report; + assert!(!report.converged); + assert!( + report + .blocker + .as_deref() + .is_some_and(|b| b.contains("MinTraceWidht") && b.contains("MinTraceWidth")), + "the refusal must name the typo and the valid options: {:?}", + report.blocker + ); + } + + #[test] + fn calibration_is_off_unless_asked_for() { + let mut pcb = test_board(); + pcb.rules.default_rules.via_diameter = 0.21; + pcb.rules.default_rules.via_drill = 0.12; + pcb.rules.min_drill = 0.2; + + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + ..Default::default() + }, + ) + .report; + assert!(!report.calibration_requested); + assert!(report.calibration.is_empty()); + assert_eq!(pcb.rules.min_drill, 0.2, "rules must be untouched"); + + let mut pcb2 = test_board(); + pcb2.rules.default_rules.via_diameter = 0.21; + pcb2.rules.default_rules.via_drill = 0.12; + pcb2.rules.min_drill = 0.2; + let report2 = run_fab_prep( + &mut pcb2, + &FabPrepOptions { + calibrate_rules: true, + route_remaining: false, + prune_dangling: false, + ..Default::default() + }, + ) + .report; + assert!(report2.calibration_requested); + assert_eq!(report2.calibration.applied.len(), 2); + assert_eq!(pcb2.rules.min_drill, 0.12); + } +} diff --git a/crates/vcad-ecad-fabprep/src/package.rs b/crates/vcad-ecad-fabprep/src/package.rs new file mode 100644 index 000000000..374fdd620 --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/package.rs @@ -0,0 +1,259 @@ +//! Writing the run's output to a directory. +//! +//! Two entry points, and the split between them is the fail-closed gate: +//! +//! * [`write_report`] always writes — the receipt, the annotated DRC dump, and +//! the board itself. A run that failed is still worth reading, and the board +//! is where the next run picks up. +//! * [`write_fab_package`] writes the fabrication files, and **refuses** when +//! the report did not converge. `export_gerber`'s clean-DRC gate exists for +//! the same reason; fab-prep is the supported way to *get* clean, never a way +//! around it. + +use std::path::Path; + +use vcad_ir::ecad::Pcb; + +use crate::{render, FabPrepOutcome}; + +/// Why a package could not be written. +#[derive(Debug, thiserror::Error)] +pub enum PackageError { + /// The fab-prep run did not converge; fabrication files are withheld. + #[error("refusing to write a fab package: {0}")] + NotConverged(String), + /// A file could not be written. + #[error("writing {path}: {source}")] + Io { + /// The file being written. + path: String, + /// Underlying error. + #[source] + source: std::io::Error, + }, + /// Gerber, Excellon, BOM or pick-and-place serialization failed. + #[error("serializing {what}: {message}")] + Serialize { + /// Which artifact failed. + what: String, + /// Underlying message. + message: String, + }, +} + +fn write(dir: &Path, name: &str, bytes: impl AsRef<[u8]>) -> Result { + let path = dir.join(name); + std::fs::write(&path, bytes).map_err(|source| PackageError::Io { + path: path.display().to_string(), + source, + })?; + Ok(name.to_string()) +} + +/// Write the receipt, the annotated DRC dump, and the board. Always safe to +/// call — a non-converging run needs these more than a converging one does. +/// +/// Returns the file names written, in write order. +pub fn write_report( + dir: &Path, + pcb: &Pcb, + outcome: &FabPrepOutcome, +) -> Result, PackageError> { + std::fs::create_dir_all(dir).map_err(|source| PackageError::Io { + path: dir.display().to_string(), + source, + })?; + let report = &outcome.report; + let written = vec![ + write( + dir, + "drc_report.txt", + render::drc_report(report, &outcome.violations), + )?, + write(dir, "FAB_NOTES.md", render::fab_notes(report))?, + write( + dir, + "fab_report.json", + serde_json::to_string_pretty(report).map_err(|e| PackageError::Serialize { + what: "fab_report.json".into(), + message: e.to_string(), + })?, + )?, + write( + dir, + "board.pcb.json", + serde_json::to_string(pcb).map_err(|e| PackageError::Serialize { + what: "board.pcb.json".into(), + message: e.to_string(), + })?, + )?, + ]; + Ok(written) +} + +/// Write the complete fabrication package: everything [`write_report`] writes, +/// plus Gerbers, the Excellon drill file, a KiCad board, the BOM, the +/// pick-and-place CSV, and (when supplied) a board SVG. +/// +/// Refuses with [`PackageError::NotConverged`] when the run did not converge. +/// The board and the receipt are still written first, so a refused call leaves +/// a directory a human can act on. +/// +/// `svg` is passed in rather than rendered here: the PCB renderer lives above +/// this crate in the dependency graph, and pulling the CAD kernel in for one +/// optional file would be a poor trade. +pub fn write_fab_package( + dir: &Path, + pcb: &Pcb, + outcome: &FabPrepOutcome, + svg: Option<&str>, +) -> Result, PackageError> { + let mut written = write_report(dir, pcb, outcome)?; + if !outcome.report.converged { + return Err(PackageError::NotConverged(format!( + "{} — the report and board were written to {} but no fabrication files were; \ + resolve the offenders in drc_report.txt and re-run", + outcome.report.headline(), + dir.display() + ))); + } + + let gerbers = + vcad_ecad_export::gerber::generate_gerbers(pcb).map_err(|e| PackageError::Serialize { + what: "gerbers".into(), + message: e.to_string(), + })?; + for (name, content) in &gerbers { + written.push(write(dir, name, content)?); + } + + let mut drill = Vec::new(); + vcad_ecad_export::excellon::write_excellon(&mut drill, pcb).map_err(|e| { + PackageError::Serialize { + what: "drill.drl".into(), + message: e.to_string(), + } + })?; + written.push(write(dir, "drill.drl", &drill)?); + + written.push(write( + dir, + "board.kicad_pcb", + vcad_ecad_symbols::write_kicad_pcb(pcb), + )?); + + let mut bom = Vec::new(); + vcad_ecad_export::bom::write_bom(&mut bom, pcb).map_err(|e| PackageError::Serialize { + what: "bom.csv".into(), + message: e.to_string(), + })?; + written.push(write(dir, "bom.csv", &bom)?); + + let mut pnp = Vec::new(); + vcad_ecad_export::pick_place::write_pick_place(&mut pnp, pcb).map_err(|e| { + PackageError::Serialize { + what: "pick_place.csv".into(), + message: e.to_string(), + } + })?; + written.push(write(dir, "pick_place.csv", &pnp)?); + + if let Some(svg) = svg { + written.push(write(dir, "board.svg", svg)?); + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_board, with_smd_pad, with_trace}; + use crate::{run_fab_prep, FabPrepOptions}; + + fn tmpdir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vcad-fabprep-test-{name}")); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn a_converged_run_writes_the_whole_package() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + let outcome = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + ..Default::default() + }, + ); + assert!(outcome.report.converged); + + let dir = tmpdir("converged"); + let written = write_fab_package(&dir, &pcb, &outcome, Some("")).expect("package"); + for expected in [ + "drc_report.txt", + "FAB_NOTES.md", + "fab_report.json", + "board.pcb.json", + "drill.drl", + "board.kicad_pcb", + "bom.csv", + "pick_place.csv", + "board.svg", + ] { + assert!(written.iter().any(|w| w == expected), "missing {expected}"); + assert!(dir.join(expected).exists(), "{expected} not on disk"); + } + assert!( + written.iter().any(|w| w.ends_with(".gbr")), + "no gerbers written: {written:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_failed_run_is_refused_but_still_leaves_the_evidence() { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R2", "1", 5.0, 0.5, "B"); + with_smd_pad(&mut pcb, "R2", "2", 5.0, 4.0, "B"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + with_trace(&mut pcb, "B", 5.0, 0.5, 5.0, 4.0); + let outcome = run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + max_rounds: 1, + ..Default::default() + }, + ); + assert!(!outcome.report.converged); + + let dir = tmpdir("refused"); + let err = write_fab_package(&dir, &pcb, &outcome, None) + .expect_err("a dirty board must never produce fab files"); + assert!(matches!(err, PackageError::NotConverged(_)), "{err}"); + + assert!(dir.join("drc_report.txt").exists(), "evidence must survive"); + assert!(dir.join("FAB_NOTES.md").exists()); + assert!(dir.join("board.pcb.json").exists()); + assert!( + !dir.join("drill.drl").exists(), + "no fabrication file may be written for a non-converged board" + ); + assert!( + std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .all(|e| !e.file_name().to_string_lossy().ends_with(".gbr")), + "no gerbers may be written for a non-converged board" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/vcad-ecad-fabprep/src/render.rs b/crates/vcad-ecad-fabprep/src/render.rs new file mode 100644 index 000000000..83ba4d72a --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/render.rs @@ -0,0 +1,513 @@ +//! Human-readable renderings of the receipt. +//! +//! Two documents, deliberately different in shape: +//! +//! * [`drc_report`] — every violation on the finished board, each line tagged +//! with whether the routing is answerable for it. A reader scanning it can +//! tell a fixture artifact from a routing fault without cross-referencing +//! anything. +//! * [`fab_notes`] — the summary a fab package ships with: the claim, the two +//! numbers behind it, the calibration log, and the honest gaps. + +use std::collections::BTreeSet; +use std::fmt::Write as _; + +use vcad_ecad_pcb::drc::{DrcSeverity, DrcViolation}; + +use crate::delta::{rule_name, DeltaMode}; +use crate::FabPrepReport; + +/// Thousands separators — a receipt that says "1867" reads worse than "1,867", +/// and these numbers are meant to be quoted. +fn commas(n: usize) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// The per-rule delta table, shared by both documents. +fn delta_table(report: &FabPrepReport) -> String { + let mut s = String::new(); + let _ = writeln!( + s, + "{:<22} {:>10} {:>10} {:>18} mode", + "rule", "baseline", "final", "route-attributable" + ); + let _ = writeln!(s, "{}", "-".repeat(78)); + for r in &report.delta.rules { + let mode = match r.mode { + DeltaMode::SetDifference => "set difference", + DeltaMode::CountDifference => "count difference", + }; + let _ = writeln!( + s, + "{:<22} {:>10} {:>10} {:>18} {mode}{}", + r.rule, + commas(r.baseline), + commas(r.final_count), + commas(r.route_attributable), + match (r.accepted, r.route_fixable) { + (true, _) => " *** ACCEPTED BY WAIVER ***", + (false, false) => " (not loop-fixable)", + (false, true) => "", + }, + ); + } + let _ = writeln!(s, "{}", "-".repeat(78)); + let _ = writeln!( + s, + "{:<22} {:>10} {:>10} {:>18}", + "TOTAL", + commas(report.delta.baseline_total), + commas(report.delta.final_total), + commas(report.delta.route_attributable_total), + ); + s +} + +/// The verdict, the delta table, the calibration log, and the remaining +/// offenders — everything except the full violation dump. +/// +/// This is what a terminal run prints: on a dense board the dump runs to +/// thousands of lines, and burying the verdict under it would defeat the point +/// of having a verdict. +pub fn summary(report: &FabPrepReport) -> String { + let mut s = String::new(); + let _ = writeln!(s, "vcad fab-prep — DRC delta report"); + let _ = writeln!(s, "================================\n"); + let _ = writeln!(s, "VERDICT: {}\n", report.headline()); + let _ = writeln!( + s, + "Baseline = this same board with every trace and via stripped, checked under the same\n\ + rules. It is not zero and is not supposed to be: an imported fixture's own land\n\ + patterns violate its own rules. Route-attributable is the difference, and it is the\n\ + only column the router is answerable for.\n" + ); + let _ = write!(s, "{}", delta_table(report)); + if !report.accepted_rules.is_empty() { + let _ = writeln!( + s, + "\nWAIVED: {} — {} route-attributable violation(s) in these rules were accepted by an\n\ + explicit caller waiver and do NOT block the verdict. They are counted above and\n\ + listed below; a reader who disagrees with the waiver can see exactly what it covers.", + report.accepted_rules.join(", "), + commas(report.delta.route_attributable_accepted), + ); + } + let _ = writeln!( + s, + "\nConnectivity: {} unconnected/islanded net violation(s) on arrival, {} on completion{}.\n\ + Every geometric violation above can be made to vanish by deleting the copper that causes\n\ + it, so this pair is the guard: a run that cleared violations by removing routing it could\n\ + not replace does not converge, however clean the table looks.", + commas(report.connectivity.on_arrival), + commas(report.connectivity.on_completion), + match report.connectivity.regression() { + 0 => String::new(), + n => format!(" — a regression of {}", commas(n)), + } + ); + + if !report.calibration.applied.is_empty() || !report.calibration.refused.is_empty() { + let _ = writeln!( + s, + "\nRule calibration (opt-in, applied to BOTH baseline and final):" + ); + for c in &report.calibration.applied { + let _ = writeln!( + s, + " {} {:.3} -> {:.3}\n {}", + c.rule, c.declared, c.calibrated, c.justification + ); + } + for r in &report.calibration.refused { + let _ = writeln!( + s, + " {} REFUSED (requested {:.3}, floor {:.3})\n {}", + r.rule, r.requested, r.floor, r.reason + ); + } + } else if report.calibration_requested { + let _ = writeln!( + s, + "\nRule calibration: requested, nothing to calibrate — the board's rules already\n\ + admit its own declared via classes and pre-existing holes." + ); + } else { + let _ = writeln!( + s, + "\nRule calibration: NOT REQUESTED — the board was judged against the rules exactly\n\ + as it declared them." + ); + } + + // Offenders first: if the run failed closed, this is what the reader came + // for and it must not be buried under thousands of baseline lines. + if !report.delta.offenders.is_empty() { + let _ = writeln!( + s, + "\nREMAINING ROUTE-ATTRIBUTABLE OFFENDERS ({}):", + commas(report.delta.offenders.len()) + ); + for o in &report.delta.offenders { + let _ = writeln!( + s, + " {} [{}] at ({:.3}, {:.3}): {}", + o.rule, o.severity, o.position[0], o.position[1], o.message + ); + } + } + s +} + +/// [`summary`] followed by every violation on the finished board, each line +/// tagged `ROUTE` or `FIXTURE`. +/// +/// `violations` must be the DRC of the board the report describes, under the +/// same (possibly calibrated) rules — [`crate::FabPrepOutcome::violations`]. +pub fn drc_report(report: &FabPrepReport, violations: &[DrcViolation]) -> String { + let mut s = summary(report); + + let key_of = |o: &crate::Offender| { + format!( + "{}|{:.3},{:.3}|{}", + o.rule, o.position[0], o.position[1], o.message + ) + }; + let attributable: BTreeSet = report.delta.offenders.iter().map(key_of).collect(); + let waived: BTreeSet = report.delta.waived.iter().map(key_of).collect(); + + let _ = writeln!(s, "\nFULL VIOLATION LIST ({}):", commas(violations.len())); + for v in violations { + let key = format!( + "{}|{:.3},{:.3}|{}", + rule_name(&v.rule), + v.position.x, + v.position.y, + v.message + ); + let tag = if attributable.contains(&key) { + "ROUTE" + } else if waived.contains(&key) { + "WAIVED" + } else { + "FIXTURE" + }; + let sev = match v.severity { + DrcSeverity::Error => "HARD", + DrcSeverity::Warning => "WARN", + }; + let _ = writeln!(s, "{tag:<8} {sev}: {}", v.message); + } + s +} + +/// The `FAB_NOTES.md` that ships inside the package. +pub fn fab_notes(report: &FabPrepReport) -> String { + let mut s = String::new(); + let _ = writeln!(s, "# vcad autorouted fab package\n"); + let _ = writeln!( + s, + "Prepared by `vcad fab-prep`. {} copper layers, {} nets, {} pads across {} footprints; \ + {} trace segments and {} vias realized.\n", + report.board.copper_layers, + commas(report.board.nets), + commas(report.board.pads), + commas(report.board.footprints), + commas(report.board.traces), + commas(report.board.vias), + ); + + let _ = writeln!(s, "## DRC status (vs the stripped-board baseline)\n"); + if report.converged { + let _ = writeln!( + s, + "Route-attributable violations: **ZERO** in every loop-fixable rule.\n" + ); + if !report.accepted_rules.is_empty() { + let _ = writeln!( + s, + "**With waivers.** {} route-attributable violation(s) in `{}` were accepted by an \ + explicit caller waiver rather than fixed. That is a deliberate, named exception — \ + read it before treating this board as clean.\n", + commas(report.delta.route_attributable_accepted), + report.accepted_rules.join("`, `"), + ); + } + } else { + let _ = writeln!( + s, + "**NOT FAB-READY.** {} route-attributable violation(s) remain — {}.\n", + commas(report.delta.route_attributable_fixable), + report.blocker.as_deref().unwrap_or("loop did not converge"), + ); + } + let _ = writeln!( + s, + "Absolute zero is not achievable on an imported fixture, and claiming it would be a \ + different claim than the one this run supports. The same board stripped of all routing \ + scores **{}** violations against its own land patterns; the finished board scores \ + **{}**. Both numbers are below. The router is answerable for the difference.\n", + commas(report.delta.baseline_total), + commas(report.delta.final_total), + ); + let _ = writeln!(s, "```"); + let _ = write!(s, "{}", delta_table(report)); + let _ = writeln!(s, "```\n"); + let _ = writeln!( + s, + "Connectivity: **{}** unconnected/islanded net violation(s) on arrival, **{}** on \ + completion. A geometric violation can always be made to vanish by deleting the copper \ + that causes it, so this pair is checked too — a run that cleared violations by removing \ + routing it could not replace does not converge.\n", + commas(report.connectivity.on_arrival), + commas(report.connectivity.on_completion), + ); + let _ = writeln!( + s, + "Connectivity rules (unconnected nets, net islands, unstitched pads) use a count \ + difference rather than a set difference: adding copper legitimately rewrites those \ + violations, so the claim there is the weaker, non-fictional one — the router left fewer \ + than it found.\n" + ); + + let _ = writeln!(s, "## Rule calibration\n"); + if !report.calibration_requested { + let _ = writeln!( + s, + "Not requested. The board was judged against the rules exactly as it declared them.\n" + ); + } else if report.calibration.is_empty() { + let _ = writeln!( + s, + "Requested; nothing to calibrate. The board's rules already admit its own declared \ + via classes and pre-existing holes.\n" + ); + } else { + let _ = writeln!( + s, + "Requested and applied to **both** the baseline and the final check — a delta taken \ + under two different rule sets would be meaningless.\n" + ); + for c in &report.calibration.applied { + let _ = writeln!( + s, + "- `{}` {:.3} → {:.3} — {}", + c.rule, c.declared, c.calibrated, c.justification + ); + } + for r in &report.calibration.refused { + let _ = writeln!( + s, + "- `{}` **refused** (asked for {:.3}, floor {:.3}) — {}", + r.rule, r.requested, r.floor, r.reason + ); + } + let _ = writeln!(s); + } + + let _ = writeln!(s, "## Routing verdict\n"); + if let Some(v) = &report.initial_verdict { + let _ = writeln!( + s, + "Opening pass over {} unrouted connection(s) in {} search window(s): **{} routed**, \ + {} proved infeasible (bottleneck-cut certificates), {} honest unknown (search budget \ + exhausted, or a path the legality oracle rejected).\n", + commas(v.connections), + commas(v.clusters), + commas(v.routed), + commas(v.proved_infeasible), + commas(v.unknown), + ); + if !v.certificates.is_empty() { + let _ = writeln!( + s, + "Infeasibility certificates (first {}):\n", + v.certificates.len() + ); + for c in &v.certificates { + let _ = writeln!(s, "- {c}"); + } + let _ = writeln!(s); + } + } else { + let _ = writeln!( + s, + "Skipped — the board was taken as routed, and only the fix loop ran.\n" + ); + } + + let _ = writeln!(s, "## Fix loop\n"); + if report.rounds.is_empty() { + let _ = writeln!( + s, + "No rounds needed: the board had no route-attributable violations to strip.\n" + ); + } else { + let _ = writeln!( + s, + "| round | attributable before | nets stripped | traces | vias | re-routed | proved | unknown |" + ); + let _ = writeln!(s, "|---|---|---|---|---|---|---|---|"); + for r in &report.rounds { + let _ = writeln!( + s, + "| {} | {} | {} | {} | {} | {} | {} | {} |", + r.round, + commas(r.attributable_before), + commas(r.offending_nets.len()), + commas(r.stripped_traces), + commas(r.stripped_vias), + commas(r.verdict.routed), + commas(r.verdict.proved_infeasible), + commas(r.verdict.unknown), + ); + } + let _ = writeln!(s); + } + let _ = writeln!( + s, + "Dangling-copper prune: {} trace(s) and {} via(s) removed (copper reaching no pad or \ + pour of its net).\n", + commas(report.pruned_traces), + commas(report.pruned_vias), + ); + + let _ = writeln!(s, "## Known gaps (honest)\n"); + let unrouted: usize = report + .initial_verdict + .iter() + .map(|v| v.proved_infeasible + v.unknown) + .sum::() + + report + .rounds + .iter() + .map(|r| r.verdict.proved_infeasible + r.verdict.unknown) + .sum::(); + if unrouted > 0 { + let _ = writeln!( + s, + "- {} connection(s) end unrouted: proved-infeasible certificates plus honest unknowns \ + from the verdict ladder. They are accounted for, not hidden.", + commas(unrouted) + ); + } + for r in &report.delta.rules { + if r.route_attributable == 0 || r.route_fixable { + continue; + } + if r.accepted { + let _ = writeln!( + s, + "- `{}` is {} above baseline and was **waived**, not fixed. The violations are \ + real and still on the board; someone decided to accept them.", + r.rule, r.route_attributable + ); + } else { + let _ = writeln!( + s, + "- `{}` is {} above baseline. The strip-and-re-route loop does not target this \ + rule — stripping a net *creates* connectivity violations, so chasing them would \ + make the loop chase its own tail. Reported, not claimed clean.", + r.rule, r.route_attributable + ); + } + } + let _ = writeln!( + s, + "- Impedance geometry is class-width, not field-solved per layer.\n" + ); + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_board, with_smd_pad, with_trace}; + use crate::{run_fab_prep, FabPrepOptions}; + + fn short_board_report() -> FabPrepReport { + let mut pcb = test_board(); + with_smd_pad(&mut pcb, "R1", "1", 2.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R1", "2", 8.0, 2.0, "A"); + with_smd_pad(&mut pcb, "R2", "1", 5.0, 0.5, "B"); + with_smd_pad(&mut pcb, "R2", "2", 5.0, 4.0, "B"); + with_trace(&mut pcb, "A", 2.0, 2.0, 8.0, 2.0); + with_trace(&mut pcb, "B", 5.0, 0.5, 5.0, 4.0); + run_fab_prep( + &mut pcb, + &FabPrepOptions { + route_remaining: false, + prune_dangling: false, + max_rounds: 1, + ..Default::default() + }, + ) + .report + } + + #[test] + fn commas_group_thousands() { + assert_eq!(commas(0), "0"); + assert_eq!(commas(980), "980"); + assert_eq!(commas(1867), "1,867"); + assert_eq!(commas(16485), "16,485"); + } + + #[test] + fn the_report_states_both_numbers_and_the_mode_per_rule() { + let report = short_board_report(); + let text = drc_report(&report, &[]); + assert!(text.contains("baseline"), "{text}"); + assert!(text.contains("route-attributable")); + assert!(text.contains("set difference")); + assert!( + text.contains("NOT REQUESTED"), + "an uncalibrated run must say so: {text}" + ); + } + + #[test] + fn a_failed_run_lists_its_offenders_up_front() { + let report = short_board_report(); + assert!(!report.converged); + let text = drc_report(&report, &[]); + assert!( + text.contains("REMAINING ROUTE-ATTRIBUTABLE OFFENDERS"), + "{text}" + ); + let notes = fab_notes(&report); + assert!(notes.contains("NOT FAB-READY"), "{notes}"); + } + + #[test] + fn notes_log_every_calibration_with_its_justification() { + let mut pcb = test_board(); + pcb.rules.default_rules.via_diameter = 0.21; + pcb.rules.default_rules.via_drill = 0.12; + pcb.rules.min_drill = 0.2; + let report = run_fab_prep( + &mut pcb, + &FabPrepOptions { + calibrate_rules: true, + route_remaining: false, + prune_dangling: false, + ..Default::default() + }, + ) + .report; + let notes = fab_notes(&report); + assert!(notes.contains("`minDrill` 0.200 → 0.120"), "{notes}"); + assert!(notes.contains("via class 'Default'"), "{notes}"); + assert!( + notes.contains("applied to **both** the baseline and the final check"), + "{notes}" + ); + } +} diff --git a/crates/vcad-ecad-fabprep/src/test_support.rs b/crates/vcad-ecad-fabprep/src/test_support.rs new file mode 100644 index 000000000..0f0e4c1e7 --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/test_support.rs @@ -0,0 +1,141 @@ +//! Small hand-built boards for the unit tests. +//! +//! Deliberately tiny: the pipeline's behaviour under a fixture baseline, a +//! strippable offender, and a fail-closed stall is all reproducible on a 2-layer +//! 10×10 mm board, and a test that takes a minute is a test nobody runs. + +use vcad_ir::ecad::{ + BoardOutline, DesignRules, Footprint, LayerStackup, NetClassRules, Pad, PadShape, PadType, Pcb, + PcbLayer, StackupLayer, Trace, +}; +use vcad_ir::Vec2; + +/// An empty 10×10 mm two-layer board with ordinary rules. +pub fn test_board() -> Pcb { + Pcb { + outline: BoardOutline { + vertices: vec![ + Vec2::new(0.0, 0.0), + Vec2::new(10.0, 0.0), + Vec2::new(10.0, 10.0), + Vec2::new(0.0, 10.0), + ], + cutouts: vec![], + thickness: 1.6, + }, + stackup: LayerStackup { + layers: vec![ + StackupLayer { + layer: PcbLayer::FCu, + copper_thickness: Some(0.035), + dielectric_thickness: Some(1.5), + dielectric_er: Some(4.5), + material: Some("FR4".into()), + }, + StackupLayer { + layer: PcbLayer::BCu, + copper_thickness: Some(0.035), + dielectric_thickness: None, + dielectric_er: None, + material: Some("FR4".into()), + }, + ], + }, + nets: vec![], + rules: DesignRules { + default_rules: NetClassRules { + name: "Default".into(), + trace_width: 0.2, + clearance: 0.2, + via_diameter: 0.6, + via_drill: 0.3, + diff_pair_gap: None, + diff_pair_width: None, + }, + class_rules: vec![], + net_class_assignments: Default::default(), + edge_clearance: 0.2, + hole_to_hole: 0.25, + min_annular_ring: 0.1, + min_drill: 0.2, + }, + footprints: vec![], + traces: vec![], + trace_arcs: vec![], + vias: vec![], + zones: vec![], + keepouts: vec![], + net_ties: vec![], + } +} + +fn footprint_mut<'a>(pcb: &'a mut Pcb, reference: &str) -> &'a mut Footprint { + if let Some(i) = pcb.footprints.iter().position(|f| f.reference == reference) { + return &mut pcb.footprints[i]; + } + pcb.footprints.push(Footprint { + reference: reference.to_string(), + value: "TEST".into(), + footprint_name: "Test:Pad".into(), + position: Vec2::new(0.0, 0.0), + rotation: 0.0, + front: true, + pads: vec![], + graphics: vec![], + model_3d: None, + properties: Default::default(), + }); + pcb.footprints.last_mut().expect("just pushed") +} + +/// Add a 0.5 mm square SMD pad on the front copper layer. +pub fn with_smd_pad(pcb: &mut Pcb, reference: &str, number: &str, x: f64, y: f64, net: &str) { + let fp = footprint_mut(pcb, reference); + fp.pads.push(Pad { + number: number.to_string(), + pad_type: PadType::SMD, + shape: PadShape::Rect { + width: 0.5, + height: 0.5, + }, + position: Vec2::new(x, y), + rotation: 0.0, + drill: None, + net: Some(net.to_string()), + layers: vec![PcbLayer::FCu], + }); +} + +/// Add a through-hole pad with the given drill diameter. +pub fn with_tht_pad(pcb: &mut Pcb, reference: &str, number: &str, x: f64, y: f64, drill: f64) { + let fp = footprint_mut(pcb, reference); + fp.pads.push(Pad { + number: number.to_string(), + pad_type: PadType::THT, + shape: PadShape::Circle { + diameter: drill + 0.3, + }, + position: Vec2::new(x, y), + rotation: 0.0, + drill: Some(vcad_ir::ecad::DrillSpec { + diameter: drill, + oval: false, + oval_height: None, + }), + net: Some(format!("N-{reference}-{number}")), + layers: vec![PcbLayer::FCu, PcbLayer::BCu], + }); +} + +/// Add a front-copper trace at the default class width. +pub fn with_trace(pcb: &mut Pcb, net: &str, x0: f64, y0: f64, x1: f64, y1: f64) { + let width = pcb.rules.default_rules.trace_width; + pcb.traces.push(Trace { + start: Vec2::new(x0, y0), + end: Vec2::new(x1, y1), + width, + layer: PcbLayer::FCu, + net: net.to_string(), + source: None, + }); +} diff --git a/crates/vcad-ecad-fabprep/src/verdict.rs b/crates/vcad-ecad-fabprep/src/verdict.rs new file mode 100644 index 000000000..8a7f45b10 --- /dev/null +++ b/crates/vcad-ecad-fabprep/src/verdict.rs @@ -0,0 +1,383 @@ +//! The verdict ladder: put every still-unrouted connection in front of the +//! COMPLETE window router and demand an answer — `Routed` (commit-quality +//! paths that survive an oracle probe), `ProvedInfeasible` (a bottleneck-cut +//! certificate), or `BudgetExhausted` (an honest unknown). +//! +//! Lifted out of `vcad-ecad-pcb/examples/cm5_verdict.rs` so the fab-prep +//! pipeline and the example driver run the same code rather than two copies +//! that drift. + +use std::collections::{BTreeMap, BTreeSet, 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::session::RouteSession; +use vcad_ecad_pcb::spatial::{CopperElement, CopperGeom}; +use vcad_ir::ecad::{Pcb, Trace, Via}; +use vcad_ir::Vec2; + +/// Search knobs for one pass of the ladder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VerdictOptions { + /// Maximum DFS node expansions per cluster before the router returns an + /// honest unknown. + pub budget: usize, + /// Maximum connections coalesced into one joint search window. + pub max_cluster: usize, +} + +impl Default for VerdictOptions { + fn default() -> Self { + Self { + budget: 5_000_000, + max_cluster: 6, + } + } +} + +/// What one pass of the ladder concluded. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct VerdictSummary { + /// Unrouted connections the pass was asked about (plane nets excluded). + pub connections: usize, + /// Joint search windows the connections were clustered into. + pub clusters: usize, + /// Connections routed and committed to the board. + pub routed: usize, + /// Connections carrying a proved-infeasible certificate. + pub proved_infeasible: usize, + /// Connections that ended as honest unknowns (budget exhausted, or a path + /// the session oracle rejected). + pub unknown: usize, + /// Infeasibility certificates, verbatim — capped so a large board's + /// receipt stays readable. + pub certificates: Vec, +} + +/// How many certificates a summary carries before it stops collecting. +const MAX_CERTIFICATES: usize = 64; + +type Cluster = (Vec2, Vec2, Vec<(String, Vec2, Vec2)>); + +/// Merged windows wider than this coarsen the router's grid pitch until +/// unrelated terminals artificially collide. +const MAX_WINDOW_MM: f64 = 20.0; + +/// Route every still-unrouted connection on `pcb`, committing only copper that +/// survives the session oracle. Mutates `pcb` in place. +pub fn route_remaining(pcb: &mut Pcb, opts: VerdictOptions) -> VerdictSummary { + // Unrouted connections = ratsnest over the board as it stands. + let mut map: BTreeMap> = BTreeMap::new(); + for fp in &pcb.footprints { + for pad in &fp.pads { + if let Some(net) = &pad.net { + if !net.is_empty() { + map.entry(net.clone()).or_default().push(NetConnection { + component_ref: fp.reference.clone(), + pin_number: pad.number.clone(), + }); + } + } + } + } + let netlist = Netlist { + nets: map + .into_iter() + .map(|(name, connections)| NetlistNet { name, connections }) + .collect(), + }; + let mut rats = compute_ratsnest(pcb, &netlist); + // Nets that own a filled zone are connected THROUGH the plane, not by + // pad-to-pad traces — the router intentionally stitches them with vias. + // Their air-wires are not unrouted work and must not enter the verdict. + let plane_nets: BTreeSet<&str> = pcb + .zones + .iter() + .filter(|z| !z.net.is_empty()) + .map(|z| z.net.as_str()) + .collect(); + rats.retain(|l| !plane_nets.contains(l.net.as_str())); + + let mut summary = VerdictSummary { + connections: rats.len(), + ..Default::default() + }; + if rats.is_empty() { + return summary; + } + + 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: an SI-class net must be routed AND committed at its + // class width, because the DRC's MinTraceWidth rule is per-net. A joint + // path at the default width lands hundreds of width violations on a board + // whose pairs are classed wider. + 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(); + + // 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. + let mut clusters: Vec = Vec::new(); + '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() < opts.max_cluster + && merged_w <= MAX_WINDOW_MM + && merged_h <= MAX_WINDOW_MM + && cc.iter().all(|(n, _, _)| n != &l.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)); + continue 'c; + } + } + clusters.push((lo, hi, vec![(l.net.clone(), l.from, l.to)])); + } + summary.clusters = clusters.len(); + + for (lo, hi, conns) in &clusters { + // Search at the widest class width in the cluster (conservative: + // guarantees the found corridors fit every member's committed width). + let cluster_width = conns + .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, + opts.budget, + ) { + CompleteOutcome::Routed(paths) => { + // 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. Probing each path against the session AFTER its + // clustermates committed makes mutual legality exact; a path + // the oracle rejects downgrades only itself to an unknown. + 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 = CopperGeom::Segment { + a, + b, + half_w: w / 2.0, + }; + session.probe(&g, l, net, session.clearance_for(net)).legal + }); + if !legal { + summary.unknown += 1; + continue; + } + summary.routed += 1; + commit_path(pcb, &mut session, net, w, path); + } + // A cluster the router answered for fewer paths than it holds + // (the zip is short) leaves the remainder unaccounted; charge + // them as unknowns rather than silently dropping them. + summary.unknown += conns.len().saturating_sub(paths.len()); + } + CompleteOutcome::ProvedInfeasible { reason } => { + summary.proved_infeasible += conns.len(); + if summary.certificates.len() < MAX_CERTIFICATES { + let names: Vec<&str> = conns.iter().map(|c| c.0.as_str()).collect(); + summary + .certificates + .push(format!("{}: {reason}", names.join(", "))); + } + } + CompleteOutcome::BudgetExhausted => summary.unknown += conns.len(), + } + } + summary +} + +/// Commit one accepted path to both the legality oracle and the board. +fn commit_path( + pcb: &mut Pcb, + session: &mut RouteSession, + net: &str, + w: f64, + path: &[(Vec2, Vec2, vcad_ir::ecad::PcbLayer)], +) { + 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.to_string(), + layer: *layer, + geom: CopperGeom::Segment { + a: *a, + b: *b, + half_w: w / 2.0, + }, + }); + pcb.traces.push(Trace { + start: *a, + end: *b, + width: w, + layer: *layer, + net: net.to_string(), + source: None, + }); + } + // A layer change between consecutive segments that share an endpoint is a + // via at that point. + for pair in path.windows(2) { + let (_, b0, l0) = pair[0]; + let (a1, _, l1) = pair[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.to_string(), + layer, + geom: CopperGeom::Disc { center: b0, r }, + }); + } + pcb.vias.push(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.to_string(), + source: None, + }); + } + } +} + +/// Remove all board-level copper (traces, trace arcs, vias) belonging to +/// `nets`. Returns `(traces, arcs, vias)` removed. +pub fn strip_nets(pcb: &mut Pcb, nets: &BTreeSet) -> (usize, usize, usize) { + let before = (pcb.traces.len(), pcb.trace_arcs.len(), pcb.vias.len()); + pcb.traces.retain(|t| !nets.contains(&t.net)); + pcb.trace_arcs.retain(|t| !nets.contains(&t.net)); + pcb.vias.retain(|v| !nets.contains(&v.net)); + ( + before.0 - pcb.traces.len(), + before.1 - pcb.trace_arcs.len(), + before.2 - pcb.vias.len(), + ) +} + +/// Nets owning board-level copper within `radius` of `at`. +/// +/// The attribution channel of last resort. Three rules — hole-to-hole, annular +/// ring, minimum drill — report a measurement and a position but name no net at +/// all, so a message-based census cannot see them and the fix loop would declare +/// them unreachable when they are in fact ordinary re-routing work: move the +/// offending via and the violation goes away. Reading the nets off the copper +/// sitting at the reported position closes that gap. +pub fn nets_near(pcb: &Pcb, at: [f64; 2], radius: f64) -> BTreeSet { + let mut out = BTreeSet::new(); + for via in &pcb.vias { + let (dx, dy) = (via.position.x - at[0], via.position.y - at[1]); + if (dx * dx + dy * dy).sqrt() <= radius + via.diameter / 2.0 { + out.insert(via.net.clone()); + } + } + for t in &pcb.traces { + if point_segment_distance(at, [t.start.x, t.start.y], [t.end.x, t.end.y]) + <= radius + t.width / 2.0 + { + out.insert(t.net.clone()); + } + } + out +} + +/// Distance from a point to a segment. +fn point_segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (abx, aby) = (b[0] - a[0], b[1] - a[1]); + let len2 = abx * abx + aby * aby; + let t = if len2 <= f64::EPSILON { + 0.0 + } else { + (((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / len2).clamp(0.0, 1.0) + }; + let (dx, dy) = (p[0] - (a[0] + t * abx), p[1] - (a[1] + t * aby)); + (dx * dx + dy * dy).sqrt() +} + +/// Every net that currently owns board-level copper. +pub fn nets_with_copper(pcb: &Pcb) -> BTreeSet { + pcb.traces + .iter() + .map(|t| t.net.clone()) + .chain(pcb.trace_arcs.iter().map(|t| t.net.clone())) + .chain(pcb.vias.iter().map(|v| v.net.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_board, with_trace}; + + #[test] + fn strip_removes_only_the_named_nets() { + let mut pcb = test_board(); + with_trace(&mut pcb, "A", 1.0, 1.0, 5.0, 1.0); + with_trace(&mut pcb, "B", 1.0, 3.0, 5.0, 3.0); + let nets: BTreeSet = ["A".to_string()].into_iter().collect(); + let (t, _, _) = strip_nets(&mut pcb, &nets); + assert_eq!(t, 1); + assert_eq!(pcb.traces.len(), 1); + assert_eq!(pcb.traces[0].net, "B"); + } + + #[test] + fn nets_with_copper_sees_traces_and_vias() { + let mut pcb = test_board(); + with_trace(&mut pcb, "A", 1.0, 1.0, 5.0, 1.0); + pcb.vias.push(Via { + position: Vec2::new(2.0, 2.0), + diameter: 0.4, + drill: 0.2, + start_layer: vcad_ir::ecad::PcbLayer::FCu, + end_layer: vcad_ir::ecad::PcbLayer::BCu, + net: "B".into(), + source: None, + }); + let nets = nets_with_copper(&pcb); + assert!(nets.contains("A") && nets.contains("B")); + } +} diff --git a/crates/vcad-kernel-wasm/Cargo.toml b/crates/vcad-kernel-wasm/Cargo.toml index b1dcd279a..7fc3a6f22 100644 --- a/crates/vcad-kernel-wasm/Cargo.toml +++ b/crates/vcad-kernel-wasm/Cargo.toml @@ -62,7 +62,7 @@ physics = ["dep:vcad-kernel-physics"] atoms = ["dep:vcad-kernel-atoms", "vcad-kernel-atoms/wasm"] slicer = ["dep:vcad-slicer", "dep:vcad-slicer-gcode", "dep:vcad-slicer-bambu"] cam = ["dep:vcad-kernel-cam"] -ecad = ["dep:vcad-design-constraints", "dep:vcad-ecad-pcb", "dep:vcad-ecad-schematic", "dep:vcad-ecad-sim", "dep:vcad-ecad-export", "dep:vcad-ecad-symbols", "dep:vcad-ecad-parts", "dep:vcad-ecad-verify"] +ecad = ["dep:vcad-design-constraints", "dep:vcad-ecad-pcb", "dep:vcad-ecad-fabprep", "dep:vcad-ecad-schematic", "dep:vcad-ecad-sim", "dep:vcad-ecad-export", "dep:vcad-ecad-symbols", "dep:vcad-ecad-parts", "dep:vcad-ecad-verify"] embroidery = ["dep:vcad-embroidery", "dep:vcad-embroidery-pes", "dep:vcad-embroidery-dst"] ts-rs = ["dep:ts-rs"] @@ -123,6 +123,14 @@ optional = true path = "../vcad-ecad-pcb" optional = true +[dependencies.vcad-ecad-fabprep] +path = "../vcad-ecad-fabprep" +optional = true +# The kernel runs the pipeline and hands the fixed board back to the session; +# writing files is `export_gerber`'s job, so the exporters stay out of the wasm +# bundle. +default-features = false + [dependencies.vcad-ecad-schematic] path = "../vcad-ecad-schematic" optional = true diff --git a/crates/vcad-kernel-wasm/src/lib.rs b/crates/vcad-kernel-wasm/src/lib.rs index 1aa856c23..885ebd276 100644 --- a/crates/vcad-kernel-wasm/src/lib.rs +++ b/crates/vcad-kernel-wasm/src/lib.rs @@ -6028,6 +6028,48 @@ mod ecad_wasm { Ok(prof.pack_toml().to_string()) } + /// Run the whole fab-preparation pipeline on a board and return the fixed + /// board plus its DRC-delta receipt. + /// + /// Optionally calibrates the board's design rules from its own declared via + /// classes (logged, never silent), routes or certifies the connections it + /// arrived without, then loops — census the violations the *routing* is + /// answerable for, strip their nets, re-route through the session-probed + /// ladder — until that number is zero. Prunes dangling copper last. + /// + /// The receipt reports route-attributable violations against the same board + /// stripped of all routing, because on an imported fixture absolute zero is + /// not achievable and reporting one number would be reporting the wrong + /// thing. A run that does not converge comes back with `converged: false` + /// and the remaining offenders — it is the caller's job not to ship it. + /// + /// # Arguments + /// * `pcb_json` — JSON-serialized `Pcb` + /// * `options_json` — JSON-serialized `FabPrepOptions` (`null`/empty = defaults) + /// + /// # Returns + /// `{ report, pcb }` — the receipt, and the board to write back. + #[wasm_bindgen(js_name = ecadFabPrep)] + pub fn ecad_fab_prep(pcb_json: &str, options_json: Option) -> Result { + let mut pcb: Pcb = + serde_json::from_str(pcb_json).map_err(|e| JsError::new(&e.to_string()))?; + let opts: vcad_ecad_fabprep::FabPrepOptions = match options_json.as_deref() { + None | Some("") | Some("null") => Default::default(), + Some(json) => serde_json::from_str(json).map_err(|e| JsError::new(&e.to_string()))?, + }; + let outcome = vcad_ecad_fabprep::run_fab_prep(&mut pcb, &opts); + #[derive(serde::Serialize)] + struct Out<'a> { + report: &'a vcad_ecad_fabprep::FabPrepReport, + pcb: &'a Pcb, + } + serde_wasm_bindgen::to_value(&Out { + report: &outcome.report, + pcb: &pcb, + }) + .map_err(|e| JsError::new(&e.to_string())) + } + /// Audit one net's routing without mutating anything: length, via/layer /// count, the closest approach to other-net copper (via the router oracle), /// and any clearance/short/unconnected DRC issues it's involved in. The diff --git a/packages/engine/src/ecad.ts b/packages/engine/src/ecad.ts index 077218373..16c6722d0 100644 --- a/packages/engine/src/ecad.ts +++ b/packages/engine/src/ecad.ts @@ -322,6 +322,112 @@ export async function tryRunDrc(pcb: Pcb): Promise | null; + rounds: Record[]; + pruned_traces: number; + pruned_vias: number; + connectivity: { on_arrival: number; on_completion: number }; + accepted_rules: string[]; + delta: { + rules: { + rule: string; + baseline: number; + final_count: number; + route_attributable: number; + mode: string; + route_fixable: boolean; + accepted: boolean; + }[]; + baseline_total: number; + final_total: number; + route_attributable_total: number; + route_attributable_fixable: number; + route_attributable_accepted: number; + offenders: { + rule: string; + severity: string; + position: [number, number]; + message: string; + required: number; + nets: string[]; + }[]; + }; + board: Record; +} + +/** + * Run the whole fab-preparation pipeline and return the fixed board plus its + * DRC-delta receipt. + * + * Same three-state contract as {@link tryRunDrc}: a kernel that cannot parse the + * board reports `error`, never a converged run. The caller writes the returned + * board back to the session only on `ok` — and ships it only when + * `report.converged`. + */ +export async function runFabPrep( + pcb: Pcb, + options?: FabPrepOptions, +): Promise> { + const wasm = await loadEcadWasm(); + if (!wasm) { + return { ok: false, reason: "unavailable", message: "ECAD kernel WASM not loaded" }; + } + if (typeof wasm.ecadFabPrep !== "function") { + return { + ok: false, + reason: "unavailable", + message: "kernel WASM predates ecadFabPrep", + }; + } + try { + const out = wasm.ecadFabPrep( + JSON.stringify(pcb), + options ? JSON.stringify(options) : undefined, + ) as { report: FabPrepReport; pcb: Pcb }; + return { ok: true, value: out }; + } catch (e) { + return { ok: false, reason: "error", message: e instanceof Error ? e.message : String(e) }; + } +} + // --------------------------------------------------------------------------- // PCB Design-for-Manufacturing (fab-profile capability checks) // --------------------------------------------------------------------------- diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a735429bd..9581ceb6f 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -183,6 +183,7 @@ export { runPcbDfm, getPcbDfmPack, tryRunDrc, + runFabPrep, critiqueRoute, netContinuity, runErc, @@ -231,6 +232,8 @@ export type { DesignSolveReport, ConstraintGroupReport } from "./ecad.js"; export type { EcadProbe, DrcViolationResult, + FabPrepOptions, + FabPrepReport, PcbFabProfile, PcbDfmSeverity, PcbDfmLocation, diff --git a/packages/mcp/src/__tests__/ecad.test.ts b/packages/mcp/src/__tests__/ecad.test.ts index 4d366cfbf..04f0338eb 100644 --- a/packages/mcp/src/__tests__/ecad.test.ts +++ b/packages/mcp/src/__tests__/ecad.test.ts @@ -12,6 +12,7 @@ import { exportGerber, exportKicad, validateForFab, + fabPrep, calcImpedance, sizeImpedance, sizePdn, @@ -1616,6 +1617,80 @@ describe("ecad session flow", () => { expect(v.unverifiable).toHaveLength(0); }); + it("fab_prep reports both numbers and points at export_gerber when clean", async () => { + const created = out( + await createSchematic({ + components: [resistor("R1", 0), resistor("R2", 20)], + nets: { MID: ["R1.2", "R2.1"] }, + }), + ); + const id = created.document_id; + out(await placeComponents({ document_id: id, board_width: 50, board_height: 50 })); + + const r = out(await fabPrep({ document_id: id })); + expect(r.success).toBe(true); + expect(r.converged).toBe(true); + // The whole point of the receipt: never one number. Both the + // stripped-board baseline and the finished board are reported, and only + // the difference is charged to the router. + expect(r.drc_delta.baseline_total).toBeTypeOf("number"); + expect(r.drc_delta.final_total).toBeTypeOf("number"); + expect(r.drc_delta.route_attributable_blocking).toBe(0); + expect(r.drc_delta.baseline_note).toContain("stripped"); + expect(r.headline).toContain("stripped of all routing"); + expect(r.next_action).toContain("export_gerber"); + // fab_prep is the way to GET clean, so the gate it feeds must now pass. + expect(out(await exportGerber({ document_id: id })).success).toBe(true); + }); + + it("fab_prep logs every rule calibration with its derivation, and does nothing unasked", async () => { + const created = out( + await createSchematic({ + components: [resistor("R1", 0), resistor("R2", 20)], + nets: { MID: ["R1.2", "R2.1"] }, + }), + ); + const id = created.document_id; + out(await placeComponents({ document_id: id, board_width: 50, board_height: 50 })); + // Declare a via class the board's own global minimum forbids. + out( + await setDesignRules({ + document_id: id, + via_diameter: 0.21, + via_drill: 0.12, + min_drill: 0.2, + min_annular_ring: 0.15, + }), + ); + + const off = out(await fabPrep({ document_id: id, dry_run: true })); + expect(off.calibration.requested).toBe(false); + expect(off.calibration.applied).toHaveLength(0); + + const on = out(await fabPrep({ document_id: id, calibrate_rules: true, dry_run: true })); + expect(on.calibration.requested).toBe(true); + const drill = on.calibration.applied.find((c: { rule: string }) => c.rule === "minDrill"); + expect(drill).toBeDefined(); + expect(drill.declared).toBeCloseTo(0.2); + expect(drill.calibrated).toBeCloseTo(0.12); + expect(drill.justification).toContain("via class"); + }); + + it("fab_prep refuses a waiver naming a rule that does not exist", async () => { + const created = out( + await createSchematic({ + components: [resistor("R1", 0), resistor("R2", 20)], + nets: { MID: ["R1.2", "R2.1"] }, + }), + ); + const id = created.document_id; + out(await placeComponents({ document_id: id, board_width: 50, board_height: 50 })); + + const r = out(await fabPrep({ document_id: id, accept_rules: ["MinTraceWidht"] })); + expect(r.converged).toBe(false); + expect(r.blocker).toContain("MinTraceWidht"); + }); + it("validate_for_fab blocks a dirty board and names the DRC errors", async () => { const created = out( await createSchematic({ diff --git a/packages/mcp/src/__tests__/tool-surface.fixture.json b/packages/mcp/src/__tests__/tool-surface.fixture.json index f622b576c..399eae7a4 100644 --- a/packages/mcp/src/__tests__/tool-surface.fixture.json +++ b/packages/mcp/src/__tests__/tool-surface.fixture.json @@ -10199,6 +10199,72 @@ "readOnlyHint": false } }, + { + "name": "fab_prep", + "title": "Fab Prep", + "description": "Take a routed board to fab-ready in one call, and return the DRC-delta receipt that says what was actually achieved. Runs the whole pipeline: optional rule calibration (opt-in, every change logged with its derivation), the verdict ladder over unrouted connections (each ends Routed / ProvedInfeasible / honest-unknown), then a strip-and-re-route fix loop until the violations the ROUTING is answerable for reach zero, then a dangling-copper prune. Mutates the session document. THE RECEIPT IS THE POINT: on an imported fixture absolute zero is not achievable — the same board stripped of all routing already violates its own rules — so the report always gives BOTH numbers (stripped-board baseline and finished board) and charges only the difference to the router. Fails closed: a loop that does not converge returns `converged:false` with the remaining offenders and does not pretend otherwise, and `export_gerber`'s clean-DRC gate still stands — this is the supported way to GET clean, not a way around it.", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Session id from create_schematic or open_document. Preferred — the tool mutates the server-side session, so the full document never crosses the wire." + }, + "document": { + "type": "object", + "description": "Inline vcad IR Document (legacy stateless flow). Prefer document_id." + }, + "calibrate_rules": { + "type": "boolean", + "description": "Derive and apply design-rule calibration from the board's OWN declared via classes and pre-existing footprint holes (default FALSE). Imported boards routinely carry global minima that forbid the via class they themselves declare — e.g. a 0.21/0.12mm class under a 0.2mm minDrill, which flags every via on the board. Calibration only ever relaxes a rule to the point where the board's own GIVEN geometry stops being illegal, is floored at laser-microvia limits, and records every change with its derivation in the receipt. Off by default because silently relaxing DRC rules to make a board pass is how an unbuildable board ships." + }, + "route_remaining": { + "type": "boolean", + "description": "Route or certify the connections the board arrived without, before the fix loop (default TRUE). Each unrouted connection ends as Routed, ProvedInfeasible (with a bottleneck-cut certificate), or an honest unknown — never silently dropped." + }, + "max_rounds": { + "type": "number", + "description": "Maximum strip-and-re-route rounds (default 8). Each round censuses the violations the ROUTING is answerable for, strips those nets, and hands them back to the session-probed router." + }, + "budget": { + "type": "number", + "description": "Per-cluster search budget in node expansions for the complete router (default 5,000,000). Lower is faster and yields more honest unknowns." + }, + "max_cluster": { + "type": "number", + "description": "Maximum connections coalesced into one joint search window (default 6)." + }, + "prune_dangling": { + "type": "boolean", + "description": "Remove copper that reaches no pad or pour of its net before the final DRC (default TRUE)." + }, + "accept_rules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "DRC rule names whose route-attributable violations are ACCEPTED rather than fixed (e.g. [\"MinTraceWidth\"]). Real fab packages ship with real, named exceptions; the difference between that and a footgun is whether the exception is written down. Waived violations are still counted, still listed, and the waiver is named in the receipt — it stops blocking the verdict, it does not hide anything. An unrecognised rule name refuses the run rather than silently accepting nothing." + }, + "dry_run": { + "type": "boolean", + "description": "Compute the receipt without writing the fixed board back to the session." + } + }, + "required": [] + }, + "annotations": { + "readOnlyHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://vcad/viewer" + }, + "ui/resourceUri": "ui://vcad/viewer", + "openai/outputTemplate": "ui://vcad/viewer-openai.html", + "openai/toolInvocation/invoking": "Modeling geometry…", + "openai/toolInvocation/invoked": "Model updated" + } + }, { "name": "validate_for_fab", "title": "Validate for Fab", diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e4dee6fe5..4de07e33a 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -539,6 +539,7 @@ const LIST_TOOL_ORDER: readonly string[] = [ "run_erc", "export_gerber", "export_kicad", + "fab_prep", "validate_for_fab", "calc_impedance", "size_impedance", diff --git a/packages/mcp/src/tools/ecad.ts b/packages/mcp/src/tools/ecad.ts index 00dfc3223..094df74d2 100644 --- a/packages/mcp/src/tools/ecad.ts +++ b/packages/mcp/src/tools/ecad.ts @@ -61,6 +61,7 @@ import { exportKicadSch, tryExportFabFiles, tryRunDrc, + runFabPrep as kernelRunFabPrep, tryPcbPreviewMeshes, resolveFootprint, generateNetlist, @@ -84,7 +85,7 @@ import { verifyReceipt as kernelVerifyReceipt, netContinuity as kernelNetContinuity, } from "@vcad/engine"; -import type { Engine, NetlistResult, TriangleMesh, NetContinuity } from "@vcad/engine"; +import type { Engine, NetlistResult, TriangleMesh, NetContinuity, FabPrepReport } from "@vcad/engine"; import { registerSession, getSession, @@ -1115,6 +1116,76 @@ export const validateForFabSchema = { required: [], }; +/** JSON Schema for fab_prep tool. */ +export const fabPrepSchema = { + type: "object" as const, + properties: { + ...docInputProperties, + calibrate_rules: { + type: "boolean" as const, + description: + "Derive and apply design-rule calibration from the board's OWN declared " + + "via classes and pre-existing footprint holes (default FALSE). Imported " + + "boards routinely carry global minima that forbid the via class they " + + "themselves declare — e.g. a 0.21/0.12mm class under a 0.2mm minDrill, " + + "which flags every via on the board. Calibration only ever relaxes a rule " + + "to the point where the board's own GIVEN geometry stops being illegal, " + + "is floored at laser-microvia limits, and records every change with its " + + "derivation in the receipt. Off by default because silently relaxing DRC " + + "rules to make a board pass is how an unbuildable board ships.", + }, + route_remaining: { + type: "boolean" as const, + description: + "Route or certify the connections the board arrived without, before the " + + "fix loop (default TRUE). Each unrouted connection ends as Routed, " + + "ProvedInfeasible (with a bottleneck-cut certificate), or an honest " + + "unknown — never silently dropped.", + }, + max_rounds: { + type: "number" as const, + description: + "Maximum strip-and-re-route rounds (default 8). Each round censuses the " + + "violations the ROUTING is answerable for, strips those nets, and hands " + + "them back to the session-probed router.", + }, + budget: { + type: "number" as const, + description: + "Per-cluster search budget in node expansions for the complete router " + + "(default 5,000,000). Lower is faster and yields more honest unknowns.", + }, + max_cluster: { + type: "number" as const, + description: "Maximum connections coalesced into one joint search window (default 6).", + }, + prune_dangling: { + type: "boolean" as const, + description: + "Remove copper that reaches no pad or pour of its net before the final " + + "DRC (default TRUE).", + }, + accept_rules: { + type: "array" as const, + items: { type: "string" as const }, + description: + "DRC rule names whose route-attributable violations are ACCEPTED rather " + + 'than fixed (e.g. ["MinTraceWidth"]). Real fab packages ship with real, ' + + "named exceptions; the difference between that and a footgun is whether " + + "the exception is written down. Waived violations are still counted, " + + "still listed, and the waiver is named in the receipt — it stops blocking " + + "the verdict, it does not hide anything. An unrecognised rule name " + + "refuses the run rather than silently accepting nothing.", + }, + dry_run: { + type: "boolean" as const, + description: + "Compute the receipt without writing the fixed board back to the session.", + }, + }, + required: [], +}; + /** JSON Schema for export_kicad tool. */ export const exportKicadSchema = { type: "object" as const, @@ -5530,6 +5601,128 @@ function detectUnsupportedFeatures(pcb: Pcb): UnsupportedFeature[] { * Fail-closed throughout: a board that can't be parsed/serialized is reported * `unverifiable` (never silently "clean" or "ready"). */ +/** + * Get a routed board fab-ready in one call, and return the DRC-delta receipt + * that says what was actually achieved. + * + * Runs the whole pipeline in the kernel: optional (logged) rule calibration, + * the verdict ladder over unrouted connections, the strip-and-re-route fix loop, + * the dangling-copper prune. Mutates the session document with the fixed board. + * + * The receipt is the point. On an imported fixture "zero DRC violations" is not + * achievable, so the report never gives one number — it gives the pair: the same + * board with all routing stripped (the floor the board arrived with) and the + * finished board, with the difference charged to the routing. A run that cannot + * drive that difference to zero comes back `converged: false` with the remaining + * offenders named, and `export_gerber`'s clean-DRC gate still stands: this is the + * supported way to GET clean, never a way around the gate. + */ +export async function fabPrep(args: Record) { + const ctx = resolveDocInput(args); + const pcb = getDocPcb(ctx.doc); + if (!pcb) { + return ecadError( + "Document has no PCB — run place_components first (or open a document that has a board)", + ); + } + // Fail closed: every claim this tool makes is a DRC claim, so without the + // kernel there is nothing to say and guessing would be worse than refusing. + if (!(await isEcadAvailable())) { + return ecadError( + "fab_prep requires the kernel DRC/routing engine (ECAD WASM unavailable) — refusing to " + + "report an unverifiable fab-readiness verdict", + ); + } + + const dryRun = Boolean(args.dry_run); + const options = { + calibrate_rules: Boolean(args.calibrate_rules), + route_remaining: args.route_remaining === undefined ? true : Boolean(args.route_remaining), + prune_dangling: args.prune_dangling === undefined ? true : Boolean(args.prune_dangling), + max_rounds: Math.max(0, Math.round(Number(args.max_rounds ?? 8))), + accept_rules: Array.isArray(args.accept_rules) ? (args.accept_rules as string[]) : [], + verdict: { + budget: Math.max(1, Math.round(Number(args.budget ?? 5_000_000))), + max_cluster: Math.max(1, Math.round(Number(args.max_cluster ?? 6))), + }, + }; + + const out = await kernelRunFabPrep(pcb, options); + if (!out.ok) { + return ecadError(`fab_prep could not run: ${out.message}`); + } + const { report, pcb: fixed } = out.value; + + if (!dryRun) { + // The kernel returns a whole board; copy the copper it changed back onto the + // session's board rather than swapping the object, so anything else holding + // a reference to it (preview, receipts) sees the update. + pcb.traces = fixed.traces; + pcb.traceArcs = fixed.traceArcs; + pcb.vias = fixed.vias; + pcb.rules = fixed.rules; + } + + const payload = { + success: true, + ...(dryRun ? { dry_run: true } : {}), + converged: report.converged, + ...(report.blocker ? { blocker: report.blocker } : {}), + headline: fabPrepHeadline(report), + // Both numbers, always. A caller that sees only one of them is being told + // something other than what this run established. + drc_delta: { + baseline_total: report.delta.baseline_total, + final_total: report.delta.final_total, + route_attributable_total: report.delta.route_attributable_total, + route_attributable_blocking: report.delta.route_attributable_fixable, + route_attributable_accepted: report.delta.route_attributable_accepted, + by_rule: report.delta.rules, + baseline_note: + "baseline = this same board with every trace and via stripped, checked under the same " + + "rules. It is not zero on an imported fixture and is not supposed to be; the router is " + + "answerable for the difference, not the total.", + }, + connectivity: report.connectivity, + ...(report.accepted_rules.length > 0 ? { accepted_rules: report.accepted_rules } : {}), + calibration: { + requested: report.calibration_requested, + applied: report.calibration.applied, + refused: report.calibration.refused, + }, + initial_verdict: report.initial_verdict, + rounds: report.rounds, + pruned: { traces: report.pruned_traces, vias: report.pruned_vias }, + // Cap the offender list: a non-converging run on a dense board can name + // thousands, and the full list lives in the board's own DRC. + offenders: report.delta.offenders.slice(0, 25), + offender_count: report.delta.offenders.length, + next_action: report.converged + ? "export_gerber (the clean-DRC gate will now pass)" + : "resolve the offenders above (fix_drc / route_nets / set_placement), then re-run fab_prep", + ...docResultPayload(ctx), + }; + return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] }; +} + +/** One-sentence statement of what a fab-prep run established. */ +function fabPrepHeadline(report: FabPrepReport): string { + if (!report.converged) { + return ( + `NOT FAB-READY — ${report.delta.route_attributable_fixable} route-attributable ` + + `violation(s) remain (${report.blocker ?? "loop did not converge"})` + ); + } + const waived = + report.delta.route_attributable_accepted > 0 + ? `, ${report.delta.route_attributable_accepted} waived under ${report.accepted_rules.join("+")}` + : ""; + return ( + `zero route-attributable violations${waived} — ${report.delta.final_total} on the finished ` + + `board, ${report.delta.baseline_total} on the same board stripped of all routing` + ); +} + export async function validateForFab(args: Record) { const ctx = resolveDocInput(args); const pcb = getDocPcb(ctx.doc); @@ -14246,6 +14439,29 @@ export const toolDefs: ToolDef[] = [ handler: (a) => exportKicad(a) as ToolResult | Promise, behavior: behavior({}), }, + { + name: "fab_prep", + pack: "ecad", + description: + "Take a routed board to fab-ready in one call, and return the DRC-delta " + + "receipt that says what was actually achieved. Runs the whole pipeline: " + + "optional rule calibration (opt-in, every change logged with its " + + "derivation), the verdict ladder over unrouted connections (each ends " + + "Routed / ProvedInfeasible / honest-unknown), then a strip-and-re-route " + + "fix loop until the violations the ROUTING is answerable for reach zero, " + + "then a dangling-copper prune. Mutates the session document. " + + "THE RECEIPT IS THE POINT: on an imported fixture absolute zero is not " + + "achievable — the same board stripped of all routing already violates its " + + "own rules — so the report always gives BOTH numbers (stripped-board " + + "baseline and finished board) and charges only the difference to the " + + "router. Fails closed: a loop that does not converge returns " + + "`converged:false` with the remaining offenders and does not pretend " + + "otherwise, and `export_gerber`'s clean-DRC gate still stands — this is " + + "the supported way to GET clean, not a way around it.", + inputSchema: fabPrepSchema, + handler: (a) => fabPrep(a) as ToolResult | Promise, + behavior: behavior({ writesDoc: true, geometry: true, mount: true }), + }, { name: "validate_for_fab", pack: "ecad", diff --git a/packages/mcp/src/tools/tool-metadata.ts b/packages/mcp/src/tools/tool-metadata.ts index 9e1c6e2a9..32d21aca1 100644 --- a/packages/mcp/src/tools/tool-metadata.ts +++ b/packages/mcp/src/tools/tool-metadata.ts @@ -341,6 +341,7 @@ export const TOOL_METADATA: Record = { annotations: RW, outputSchema: objectOut({ export_kicad: { type: "object" } }), }, + fab_prep: { title: "Fab Prep", annotations: RW }, validate_for_fab: { title: "Validate for Fab", annotations: RO }, calc_impedance: { title: "Calc Impedance", annotations: RO }, size_impedance: { title: "Size Impedance", annotations: RO },