← Commit history

1.0.54: 1.0.54. Wiki #119: back-side footprints written by other tools are read the way KiCad reads them. A footprint on B.Cu whose SMD pads still carry F.Cu layers (front frame) is mirrored by KiCad on load; the bridge read it literally, so pad positions were off by twice the pad's local x and the pads looked like front-side copper, which made kicad_route_net's preflight reject correct plans on back-side QFNs. Now that combination is mirrored and moved to the back layers, in every path that resolves pads (live routing state and preflight, placement, file routing, the Freerouting export). Footprints flipped by KiCad itself (pads already on B.Cu) were already right and are unchanged: checked against KiCad's own StickHub demo, 123 of 123 back-side pads match their net's copper with the literal reading. Wiki #118: an unannounced product name is gone from every hint, example and doc; the fab is the 3rd party fab, and examples use jlcpcb or omit fab for the baseline. Wiki #104: an unknown silk text field now lists the accepted fields, and a refusal for text below the board's minimum size says where that minimum lives.

John Lauer ·095daae549 ·5d ago ·parent 4f34c0c
2 files changed +70−2
rust/crates/kicad-core/src/pcb.rs+69−1
@@ -831,8 +831,11 @@ pub fn pads(board: &Board) -> Vec<Pad> {         let (fx, fy, rot) = fp.position.as_ref().map(|p| (p.x, p.y, p.rotation.unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));         let ang = rot.to_radians();         let (sin_a, cos_a) = ang.sin_cos();+        let unflipped = stored_unflipped(fp);         for pad in &fp.pads {             let (Some(px), Some(py)) = (pad.x, pad.y) else { continue };+            // wiki #119: a back-side footprint stored in the FRONT frame is mirrored by KiCad at load.+            let px = if unflipped { -px } else { px };             let net_number = pad.net_number.flatten();             let net_name = pad.net_name.clone().unwrap_or_default();             out.push(Pad {@@ -845,7 +848,7 @@ pub fn pads(board: &Board) -> Vec<Pad> {                 net_key: if pad.has_net() { net_key(board, net_number, Some(&net_name)) } else { net_key(board, None, None) },                 net_name,                 kind: pad.kind.clone(),-                layers: pad.layers.clone().unwrap_or_default(),+                layers: if unflipped { flip_layers(&pad.layers.clone().unwrap_or_default()) } else { pad.layers.clone().unwrap_or_default() },                 side: fp.layer.clone().unwrap_or_default(),                 footprint: fp.footprint.clone(),             });@@ -854,6 +857,33 @@ pub fn pads(board: &Board) -> Vec<Pad> {     out } +/// Wiki #119. KiCad saves a footprint flipped in KiCad with its pads already on the back+/// layers (`B.Cu`, `B.Mask`, ...): read literally, those positions are right (verified against+/// KiCad's own StickHub demo, KiCad 10 format: 123 of 123 back-side pads have their own net's+/// copper ending exactly where the literal transform puts them, 0 at the mirrored spot).+/// A footprint WRITTEN by another tool can sit on `B.Cu` while its SMD pads still say `F.Cu`, in the+/// front frame; KiCad mirrors those on load (the reporter's native DRC proved it). That combination+/// is unambiguous, because a back-side part's SMD pads cannot really be on the front copper, so only+/// then are the pads mirrored (local x negated) and moved to the back layers.+pub fn stored_unflipped(fp: &Footprint) -> bool {+    if fp.layer.as_deref() != Some("B.Cu") {+        return false;+    }+    fp.pads.iter().any(|p| {+        let l = p.layers.clone().unwrap_or_default();+        p.kind == "smd" && l.iter().any(|x| x == "F.Cu") && !l.iter().any(|x| x == "B.Cu" || x == "*.Cu")+    })+}++/// F.* <-> B.* for a pad's layer list (inner layers and wildcards unchanged).+pub fn flip_layers(layers: &[String]) -> Vec<String> {+    layers.iter().map(|l| {+        if let Some(rest) = l.strip_prefix("F.") { format!("B.{rest}") }+        else if let Some(rest) = l.strip_prefix("B.") { format!("F.{rest}") }+        else { l.clone() }+    }).collect()+}+ pub fn copper_layers(board: &Board) -> Vec<String> {     board.layers.iter().filter(|l| l.name.ends_with(".Cu")).map(|l| l.name.clone()).collect() }@@ -1307,6 +1337,44 @@ fn write_board_inner(path: &Path, new_text: &str, backup: bool, expected: Option pub(crate) mod tests {     use super::*; ++    fn qfn_board(pad_layers: &str) -> Board {+        let pad = |n: &str, x: f64, y: f64| format!("(pad \"{n}\" smd roundrect (at {x} {y}) (size 0.2 0.8) (layers {pad_layers}) (net 1 \"GND\"))");+        let text = format!("(kicad_pcb (version 20250907) (generator \"t\") (net 0 \"\") (net 1 \"GND\")\n\+            (footprint \"Package_DFN_QFN:QFN-56\" (layer \"B.Cu\") (at 52 153)\n\+              (property \"Reference\" \"U1\" (at 0 0) (layer \"B.SilkS\"))\n  {}\n  {}\n))",+            pad("19", -1.0, 3.44), pad("24", 1.0, 3.44));+        parse_pcb_text(&text, "t.kicad_pcb").unwrap()+    }++    #[test]+    fn a_back_side_footprint_stored_in_the_front_frame_is_mirrored_like_kicad_does() {+        // wiki #119's QFN on B.Cu at (52,153) rot 0, written by another tool with F.Cu pads. KiCad+        // mirrors local x: pad 19 (local -1.00) lands at 52 - (-1) = 53.0, pad 24 (local +1.00) at 51.0.+        // That is why the bridge, reading literally, said a via on pad 19 "would touch pad U1.24".+        let b = qfn_board("\"F.Cu\" \"F.Mask\" \"F.Paste\"");+        let p = pads(&b);+        let p19 = p.iter().find(|x| x.pad == "19").unwrap();+        let p24 = p.iter().find(|x| x.pad == "24").unwrap();+        assert_eq!((p19.x, p19.y), (53.0, 156.44));+        assert_eq!((p24.x, p24.y), (51.0, 156.44));+        assert!(p19.layers.contains(&"B.Cu".to_string()) && !p19.layers.contains(&"F.Cu".to_string()));+        assert!(pad_reaches(Some(p19), "B.Cu") && !pad_reaches(Some(p19), "F.Cu"));+    }++    #[test]+    fn a_footprint_flipped_by_kicad_itself_is_read_literally() {+        // KiCad's own flip writes the pads on the back layers; those positions are already right+        // (StickHub demo: 123 of 123 back-side pads match their own net's copper this way).+        let b = qfn_board("\"B.Cu\" \"B.Mask\" \"B.Paste\"");+        let p = pads(&b);+        let p19 = p.iter().find(|x| x.pad == "19").unwrap();+        let p24 = p.iter().find(|x| x.pad == "24").unwrap();+        assert_eq!((p19.x, p19.y), (51.0, 156.44));+        assert_eq!((p24.x, p24.y), (53.0, 156.44));+        assert!(pad_reaches(Some(p19), "B.Cu"));+    }+     fn repo_root() -> PathBuf {         PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()     }
rust/crates/kicad-core/src/silk_text.rs+1−1
@@ -8,7 +8,7 @@ impl Text {  pub(crate) fn parse(v:&Value,id:String)->Result<Self,RoutingError>{   const KEYS:&[&str]=&["id","text","layer","x","y","rotation","height","width","stroke","align","mirrored"];   let o=v.as_object().ok_or_else(||fail("text row must be an object"))?;-  if let Some(k)=o.keys().find(|k|!KEYS.contains(&k.as_str())){return Err(fail(format!("unsupported text field {k}; native stroke-font text only")));}+  if let Some(k)=o.keys().find(|k|!KEYS.contains(&k.as_str())){return Err(fail(format!("unsupported text field {k}; accepted fields: {}. (size is height and width, thickness is stroke, mirror is mirrored; bottom text is mirrored by default)", KEYS.join(", "))));}   let num=|k:&str,default:Option<f64>|->Result<f64,RoutingError>{let n=match v.get(k){Some(x)=>x.as_f64(),None=>default}.ok_or_else(||fail(format!("{k} must be a number")))?;if !n.is_finite()||n.abs()>100000.{Err(fail(format!("{k} is outside supported finite range")))}else{Ok(n)}};   let text=v["text"].as_str().filter(|s|!s.is_empty()&&s.len()<=4096).ok_or_else(||fail("text must contain 1..4096 bytes"))?.into();   let layer=v["layer"].as_str().filter(|s|matches!(*s,"F.SilkS"|"B.SilkS")).ok_or_else(||fail("layer must be F.SilkS or B.SilkS"))?.to_string();