app
service-kicad
Public Made by Adomby adom
Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.
master
John Lauer
Honest DRC/ERC reports: derive counts from native findings, exit 5 on error findings, ERC coordinate-scale check, load…
fa1d9ce
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
//! Input-relative sanity check for ERC item positions.
//!
//! KiCad 10.0 `eeschema/erc/erc_report.cpp`: `GetTextReport()` builds
//! `UNITS_PROVIDER( schIUScale, ...)` but `WriteJsonReport()` builds
//! `UNITS_PROVIDER( pcbIUScale, ...)`. Schematic IU is 1e4 per mm, PCB IU is
//! 1e6 per mm, so every JSON `pos` is the true millimetre value divided by
//! 100. This check does not trust that story blindly: it matches ERC items
//! to the symbols they name in the uploaded schematic and asks which
//! factor puts them nearest. Nothing is rescaled; the caller decides.
use crate::sexpr::{parse, Node};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ScaleCheck {
/// `consistent` | `scaled` | `not_checked`
pub status: &'static str,
/// Multiply report positions by this to get source millimetres.
/// `Some(1.0)` when consistent.
pub factor: Option<f64>,
/// ERC items matched to a source symbol and used in the vote.
pub matched_items: usize,
/// Median distance (mm) between item and its symbol anchor at the
/// chosen factor, and at factor 1.
pub median_distance_mm: Option<f64>,
pub median_distance_at_unity_mm: Option<f64>,
pub reason: String,
}
const CANDIDATE_FACTORS: [f64; 4] = [1.0, 10.0, 100.0, 1000.0];
/// A pin belongs within this radius of its symbol anchor on any sane sheet.
const MAX_PLAUSIBLE_MM: f64 = 60.0;
/// Reference designator -> anchor positions (multi-unit symbols have several).
fn symbol_anchors(root: &Node) -> BTreeMap<String, Vec<(f64, f64)>> {
let mut map: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();
for sym in root.children("symbol") {
let Some(at) = sym.child("at") else { continue };
let (Some(x), Some(y)) = (at.items().get(1).and_then(Node::as_f64), at.items().get(2).and_then(Node::as_f64)) else { continue };
let reference = sym.children("property").find_map(|p| {
let items = p.items();
(items.get(1).and_then(Node::text) == Some("Reference")).then(|| items.get(2).and_then(Node::text)).flatten()
});
if let Some(r) = reference {
map.entry(r.to_string()).or_default().push((x, y));
}
}
map
}
/// "Symbol U1 Pin 3 [EN, Input, Line]" -> "U1"
fn item_reference(description: &str) -> Option<&str> {
description.strip_prefix("Symbol ")?.split_whitespace().next()
}
fn median(v: &mut Vec<f64>) -> Option<f64> {
if v.is_empty() { return None; }
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
Some(v[v.len() / 2])
}
fn not_checked(reason: impl Into<String>) -> ScaleCheck {
ScaleCheck { status: "not_checked", factor: None, matched_items: 0, median_distance_mm: None, median_distance_at_unity_mm: None, reason: reason.into() }
}
pub fn check_erc_scale(schematic_bytes: &[u8], report: &Value) -> ScaleCheck {
let Ok(text) = std::str::from_utf8(schematic_bytes) else { return not_checked("schematic is not UTF-8") };
let root = match parse(text) { Ok(r) => r, Err(e) => return not_checked(format!("schematic did not parse: {e}")) };
let anchors = symbol_anchors(&root);
if anchors.is_empty() { return not_checked("no placed symbols with a Reference in the schematic"); }
// (item pos, candidate anchors)
let mut pairs: Vec<((f64, f64), &Vec<(f64, f64)>)> = Vec::new();
for sheet in report.get("sheets").and_then(Value::as_array).into_iter().flatten() {
for v in sheet.get("violations").and_then(Value::as_array).into_iter().flatten() {
for item in v.get("items").and_then(Value::as_array).into_iter().flatten() {
let Some(reference) = item.get("description").and_then(Value::as_str).and_then(item_reference) else { continue };
let Some(list) = anchors.get(reference) else { continue };
let (Some(x), Some(y)) = (item.pointer("/pos/x").and_then(Value::as_f64), item.pointer("/pos/y").and_then(Value::as_f64)) else { continue };
pairs.push(((x, y), list));
}
}
}
if pairs.is_empty() { return not_checked("no ERC item names a symbol present in the schematic"); }
let median_for = |factor: f64| {
let mut d: Vec<f64> = pairs.iter().map(|((x, y), list)| {
list.iter().map(|(ax, ay)| ((x * factor - ax).powi(2) + (y * factor - ay).powi(2)).sqrt())
.fold(f64::INFINITY, f64::min)
}).collect();
median(&mut d).unwrap_or(f64::INFINITY)
};
let scored: Vec<(f64, f64)> = CANDIDATE_FACTORS.iter().map(|f| (*f, median_for(*f))).collect();
let unity = scored[0].1;
let (best, best_d) = scored.iter().copied().fold((1.0, f64::INFINITY), |acc, s| if s.1 < acc.1 { s } else { acc });
let matched_items = pairs.len();
if best == 1.0 || best_d > MAX_PLAUSIBLE_MM || unity <= best_d * 3.0 {
let status = if unity <= MAX_PLAUSIBLE_MM { "consistent" } else { "not_checked" };
return ScaleCheck {
status, factor: (status == "consistent").then_some(1.0), matched_items,
median_distance_mm: Some(unity), median_distance_at_unity_mm: Some(unity),
reason: if status == "consistent" { format!("{matched_items} ERC item(s) sit within {unity:.2} mm (median) of the symbols they name") }
else { format!("no candidate factor places the {matched_items} matched item(s) near their symbols (median {unity:.2} mm at 1x)") },
};
}
ScaleCheck {
status: "scaled", factor: Some(best), matched_items,
median_distance_mm: Some(best_d), median_distance_at_unity_mm: Some(unity),
reason: format!(
"{matched_items} ERC item(s) land a median {best_d:.2} mm from the symbols they name only after multiplying pos by {best:.0} (at 1x the median is {unity:.2} mm)"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture(name: &str) -> Vec<u8> {
std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
}
#[test]
fn issue_2_kicad_10_erc_json_is_100x_small() {
let report: Value = serde_json::from_slice(&fixture("erc-native-scaled.json")).unwrap();
let c = check_erc_scale(&fixture("erc-source-scaled.kicad_sch"), &report);
assert_eq!(c.status, "scaled", "{c:?}");
assert_eq!(c.factor, Some(100.0));
assert!(c.matched_items >= 3);
assert!(c.median_distance_mm.unwrap() < 30.0, "{c:?}");
}
#[test]
fn consistent_report_is_not_flagged() {
let sch = br#"(kicad_sch (version 20250114) (symbol (lib_id "X:Y") (at 80 86.59 0) (property "Reference" "U1" (at 0 0 0))))"#;
let report = json!({"sheets": [{"violations": [{"severity":"error","items":[{"description":"Symbol U1 Pin 3 [EN]","pos":{"x":52.06,"y":67.54}}]}]}]});
let c = check_erc_scale(sch, &report);
assert_eq!(c.status, "consistent", "{c:?}");
assert_eq!(c.factor, Some(1.0));
}
#[test]
fn scaled_synthetic_is_flagged() {
let sch = br#"(kicad_sch (version 20250114) (symbol (lib_id "X:Y") (at 80 86.59 0) (property "Reference" "U1" (at 0 0 0))))"#;
let report = json!({"sheets": [{"violations": [{"severity":"error","items":[{"description":"Symbol U1 Pin 3 [EN]","pos":{"x":0.5206,"y":0.6754}}]}]}]});
let c = check_erc_scale(sch, &report);
assert_eq!(c.status, "scaled", "{c:?}");
assert_eq!(c.factor, Some(100.0));
}
#[test]
fn nothing_to_match_is_not_checked() {
let sch = br#"(kicad_sch (version 20250114) (wire (pts (xy 1 1) (xy 2 2))))"#;
let c = check_erc_scale(sch, &json!({"sheets": []}));
assert_eq!(c.status, "not_checked");
}
}