app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.56
73fcf19
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
//! Footprint and board graphics as geometry: the visible silkscreen text boxes and strokes (F.SilkS,
//! B.SilkS), and the copper graphics a footprint carries besides its pads. A planner that puts a
//! via or a part near them reads them here (issue #90: heatsink vias under a part's own labels).
use crate::geom::{self, Pt};
use crate::sx::{Item, Node};
use crate::{reference_of, Board};
/// One piece of board graphics: a filled box (a text's extent), a stroke (a line of half width
/// `half`), or a filled polygon.
#[derive(Clone, Debug)]
pub enum Shape {
Box { x0: f64, y0: f64, x1: f64, y1: f64, layer: String, what: String },
Stroke { a: Pt, b: Pt, half: f64, layer: String, what: String },
Poly { pts: Vec<Pt>, layer: String, what: String },
}
impl Shape {
/// Distance from a point to the shape's ink (0 inside it).
pub fn distance(&self, p: Pt) -> f64 {
match self {
Shape::Box { x0, y0, x1, y1, .. } => {
let dx = (x0 - p.0).max(p.0 - x1).max(0.0);
let dy = (y0 - p.1).max(p.1 - y1).max(0.0);
dx.hypot(dy)
}
Shape::Stroke { a, b, half, .. } => (geom::seg_distance(p.0, p.1, a.0, a.1, b.0, b.1) - half).max(0.0),
Shape::Poly { pts, .. } => geom::point_poly_distance(p.0, p.1, pts),
}
}
pub fn layer(&self) -> &str {
match self { Shape::Box { layer, .. } | Shape::Stroke { layer, .. } | Shape::Poly { layer, .. } => layer }
}
pub fn what(&self) -> &str {
match self { Shape::Box { what, .. } | Shape::Stroke { what, .. } | Shape::Poly { what, .. } => what }
}
}
/// KiCad's stroke font, as a lower bound: about 0.6 x the glyph width per character (0.3 for the narrow
/// ones), lines of the glyph height, plus the stroke. Returns the axis-aligned (width, height) of the
/// text turned by `angle` degrees. A box smaller than this cannot hold the text.
pub fn text_extent(text: &str, size_h: f64, size_w: f64, thick: f64, angle: f64) -> (f64, f64) {
let lines: Vec<&str> = text.split('\n').collect();
let per = |c: char| if "iljI1.,:;'!|` ".contains(c) { 0.3 } else { 0.6 };
let wide = lines.iter().map(|l| l.chars().map(per).sum::<f64>()).fold(0.0, f64::max) * size_w + thick;
let high = lines.len() as f64 * size_h + thick;
let (s, c) = angle.to_radians().sin_cos();
(wide * c.abs() + high * s.abs(), wide * s.abs() + high * c.abs())
}
fn layer_of(n: &Node) -> Option<&str> { n.find("layer").and_then(|l| l.value()) }
fn hidden(n: &Node) -> bool {
let h = |m: &Node| m.find("hide").map(|x| x.value() != Some("no")).unwrap_or(false) || m.items.iter().any(|it| matches!(it, Item::Atom { text, .. } if text == "hide"));
h(n) || n.find("effects").map(h).unwrap_or(false)
}
fn xy(n: Option<&Node>) -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) }
fn stroke_width(n: &Node) -> f64 {
n.find("stroke").and_then(|s| s.find("width")).and_then(|w| w.number(1)).or_else(|| n.find("width").and_then(|w| w.number(1))).unwrap_or(0.12)
}
fn filled(n: &Node) -> bool {
n.find("fill").and_then(|f| f.value()).map(|v| v == "yes" || v == "solid").unwrap_or(false)
}
/// The visible text box of a text node (a footprint's property or fp_text, or a gr_text), its anchor at
/// `at` (already in board coordinates), honouring left/right/top/bottom justification.
fn text_box(n: &Node, text: &str, at: Pt, angle: f64, layer: &str, what: String) -> Shape {
let (cx, cy, ew, eh) = text_frame(n, text, at, angle).0;
Shape::Box { x0: cx - ew / 2.0, y0: cy - eh / 2.0, x1: cx + ew / 2.0, y1: cy + eh / 2.0, layer: layer.to_string(), what }
}
/// Issue #153: the text's own (turned) rectangle, four corners in board coordinates: a 45 degree label
/// is this quad, not the axis-aligned box around it (about twice its area).
pub fn text_quad(n: &Node, text: &str, at: Pt, angle: f64) -> Vec<Pt> {
let ((cx, cy, _, _), (uw, uh)) = text_frame(n, text, at, angle);
[(-uw / 2.0, -uh / 2.0), (uw / 2.0, -uh / 2.0), (uw / 2.0, uh / 2.0), (-uw / 2.0, uh / 2.0)].iter()
.map(|&(x, y)| { let (dx, dy) = geom::rot_kicad(x, y, angle); (cx + dx, cy + dy) }).collect()
}
/// ((centre x, centre y, turned width, turned height), (unturned width, height)) of a text node.
fn text_frame(n: &Node, text: &str, at: Pt, angle: f64) -> ((f64, f64, f64, f64), (f64, f64)) {
let f = n.find("effects").and_then(|e| e.find("font"));
let size = f.and_then(|f| f.find("size"));
let h = size.and_then(|s| s.number(1)).unwrap_or(1.0);
let w = size.and_then(|s| s.number(2)).unwrap_or(h);
let t = f.and_then(|f| f.find("thickness")).and_then(|t| t.number(1)).unwrap_or(0.15 * h);
let (ew, eh) = text_extent(text, h, w, t, angle);
let (uw, uh) = text_extent(text, h, w, t, 0.0);
let just: Vec<String> = n.find("effects").and_then(|e| e.find("justify")).map(|j| (1..).map_while(|i| j.atom(i)).map(str::to_string).collect()).unwrap_or_default();
let mirrored = just.iter().any(|j| j == "mirror");
let has = |k: &str| just.iter().any(|j| j == k);
// the box centre in the text's own frame: a left-justified text runs from its anchor to the right
let mut ox = if has("left") { uw / 2.0 } else if has("right") { -uw / 2.0 } else { 0.0 };
if mirrored { ox = -ox; }
let oy = if has("top") { uh / 2.0 } else if has("bottom") { -uh / 2.0 } else { 0.0 };
let (dx, dy) = geom::rot_kicad(ox, oy, angle);
let (cx, cy) = (at.0 + dx, at.1 + dy);
((cx, cy, ew, eh), (uw, uh))
}
/// One visible silkscreen text: its owner footprint (None for board text), kind (reference, value,
/// user...), the printed text, layer and turned rectangle (`text_quad`).
#[derive(Clone, Debug)]
pub struct TextQuad { pub owner: Option<String>, pub kind: String, pub text: String, pub layer: String, pub quad: Vec<Pt> }
/// Every visible text on the layers `keep` accepts, as its turned rectangle (issue #153).
pub fn text_quads(board: &Board, keep: &dyn Fn(&str) -> bool) -> Vec<TextQuad> {
let mut out = Vec::new();
for n in board.root.nodes() {
match n.tag.as_str() {
"footprint" => {
let reference = reference_of(n).unwrap_or_default();
let value = n.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();
let at = n.find("at");
let (fx, fy, fr) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));
for c in n.nodes().filter(|c| c.tag == "property" || c.tag == "fp_text") {
let Some(layer) = layer_of(c) else { continue };
if !keep(layer) || hidden(c) { continue; }
let text = c.atom(2).unwrap_or("").replace("${REFERENCE}", &reference).replace("${VALUE}", &value);
if text.trim().is_empty() { continue; }
let ta = c.find("at");
let (dx, dy) = geom::rot_kicad(ta.and_then(|a| a.number(1)).unwrap_or(0.0), ta.and_then(|a| a.number(2)).unwrap_or(0.0), fr);
let kind = if c.tag == "property" { c.atom(1).unwrap_or("text").to_ascii_lowercase() } else { c.atom(1).unwrap_or("text").to_string() };
let quad = text_quad(c, &text, (fx + dx, fy + dy), ta.and_then(|a| a.number(3)).unwrap_or(0.0));
out.push(TextQuad { owner: Some(reference.clone()), kind, text, layer: layer.to_string(), quad });
}
}
"gr_text" => {
let Some(layer) = layer_of(n) else { continue };
if !keep(layer) || hidden(n) { continue; }
let text = n.atom(1).unwrap_or("").to_string();
if text.trim().is_empty() { continue; }
let a = n.find("at");
let quad = text_quad(n, &text, (a.and_then(|a| a.number(1)).unwrap_or(0.0), a.and_then(|a| a.number(2)).unwrap_or(0.0)), a.and_then(|a| a.number(3)).unwrap_or(0.0));
out.push(TextQuad { owner: None, kind: "text".into(), text, layer: layer.to_string(), quad });
}
_ => {}
}
}
out
}
/// The strokes and fills of one graphic item (fp_* in a footprint's frame through `to_board`, or gr_*).
fn graphic(n: &Node, layer: &str, what: &str, to_board: &dyn Fn(Pt) -> Pt, out: &mut Vec<Shape>) {
let half = stroke_width(n) / 2.0;
let chain = |pts: &[Pt], closed: bool, out: &mut Vec<Shape>| {
let m = pts.len();
if m < 2 { return; }
let k = if closed { m } else { m - 1 };
for i in 0..k { out.push(Shape::Stroke { a: pts[i], b: pts[(i + 1) % m], half, layer: layer.to_string(), what: what.to_string() }); }
if closed && filled(n) && m >= 3 { out.push(Shape::Poly { pts: pts.to_vec(), layer: layer.to_string(), what: what.to_string() }); }
};
let kind = n.tag.trim_start_matches("fp_").trim_start_matches("gr_");
match kind {
"line" => if let (Some(s), Some(e)) = (xy(n.find("start")), xy(n.find("end"))) { chain(&[to_board(s), to_board(e)], false, out) },
"rect" => if let (Some(s), Some(e)) = (xy(n.find("start")), xy(n.find("end"))) {
let pts: Vec<Pt> = [s, (e.0, s.1), e, (s.0, e.1)].iter().map(|&p| to_board(p)).collect();
chain(&pts, true, out)
},
"circle" => if let (Some(c), Some(e)) = (xy(n.find("center")), xy(n.find("end"))) {
let r = (e.0 - c.0).hypot(e.1 - c.1);
let pts: Vec<Pt> = geom::circle_poly(c.0, c.1, r, 32).into_iter().map(to_board).collect();
chain(&pts, true, out)
},
"arc" => if let (Some(s), Some(m), Some(e)) = (xy(n.find("start")), xy(n.find("mid")), xy(n.find("end"))) {
let pts: Vec<Pt> = geom::arc_points(s, m, e, 0.25).into_iter().map(to_board).collect();
chain(&pts, false, out)
},
"poly" => if let Some(p) = n.find("pts") {
let pts: Vec<Pt> = p.find_all("xy").filter_map(|q| xy(Some(q))).map(to_board).collect();
chain(&pts, true, out)
},
_ => {}
}
}
/// Every graphic and visible text on the layers `keep` accepts, as shapes in board coordinates.
/// Footprint text (`property` and `fp_text`) counts when it is shown; `${REFERENCE}` and `${VALUE}` are
/// expanded so the box is the printed text's.
pub fn shapes(board: &Board, keep: &dyn Fn(&str) -> bool) -> Vec<Shape> {
let mut out = Vec::new();
for n in board.root.nodes() {
match n.tag.as_str() {
"footprint" => {
let reference = reference_of(n).unwrap_or_default();
let value = n.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();
let at = n.find("at");
let (fx, fy, fr) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));
let to_board = move |p: Pt| { let (dx, dy) = geom::rot_kicad(p.0, p.1, fr); (fx + dx, fy + dy) };
for c in n.nodes() {
let Some(layer) = layer_of(c) else { continue };
if !keep(layer) { continue; }
match c.tag.as_str() {
"property" | "fp_text" => {
if hidden(c) { continue; }
let raw = c.atom(2).unwrap_or("");
let text = raw.replace("${REFERENCE}", &reference).replace("${VALUE}", &value);
if text.trim().is_empty() { continue; }
let ta = c.find("at");
let p = to_board((ta.and_then(|a| a.number(1)).unwrap_or(0.0), ta.and_then(|a| a.number(2)).unwrap_or(0.0)));
let kind = if c.tag == "property" { c.atom(1).unwrap_or("text").to_ascii_lowercase() } else { c.atom(1).unwrap_or("text").to_string() };
out.push(text_box(c, &text, p, ta.and_then(|a| a.number(3)).unwrap_or(0.0), layer, format!("{reference} {kind} text \"{text}\"")));
}
t if t.starts_with("fp_") => graphic(c, layer, &format!("{reference} {}", &t[3..]), &to_board, &mut out),
_ => {}
}
}
}
"gr_text" => {
let Some(layer) = layer_of(n) else { continue };
if !keep(layer) || hidden(n) { continue; }
let text = n.atom(1).unwrap_or("").to_string();
if text.trim().is_empty() { continue; }
let a = n.find("at");
let p = (a.and_then(|a| a.number(1)).unwrap_or(0.0), a.and_then(|a| a.number(2)).unwrap_or(0.0));
out.push(text_box(n, &text, p, a.and_then(|a| a.number(3)).unwrap_or(0.0), layer, format!("board text \"{text}\"")));
}
t if t.starts_with("gr_") => {
let Some(layer) = layer_of(n) else { continue };
if keep(layer) { graphic(n, layer, &format!("board {}", &t[3..]), &|p| p, &mut out); }
}
_ => {}
}
}
out
}
/// The silkscreen on both sides: visible text boxes and graphics on F.SilkS and B.SilkS.
pub fn silk_shapes(board: &Board) -> Vec<Shape> {
shapes(board, &|l| l == "F.SilkS" || l == "B.SilkS")
}
/// Nearest silkscreen shape closer than `gap` to a point, if any.
pub fn near<'a>(silk: &'a [Shape], p: Pt, gap: f64) -> Option<&'a Shape> {
silk.iter().find(|s| s.distance(p) < gap)
}
#[cfg(test)]
mod tests {
#[test]
fn labels_and_outlines_are_shapes_in_board_coordinates() {
let b = crate::Board::from_text("t", r#"(kicad_pcb (net 0 "") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))
(gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))
(gr_text "HELLO" (at 20 20 0) (layer "B.SilkS") (effects (font (size 1 1) (thickness 0.15))))
(gr_line (start 2 2) (end 8 2) (layer "Dwgs.User") (width 0.1))
(footprint "x" (layer "F.Cu") (at 10 10 90)
(property "Reference" "U3" (at 0 -2 90) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))
(property "Value" "LDO" (at 0 2 90) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))
(property "Footprint" "x" (at 0 0 0) (layer "F.SilkS") (hide yes) (effects (font (size 1 1))))
(fp_line (start -1 -1) (end 1 -1) (stroke (width 0.12) (type solid)) (layer "F.SilkS"))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu"))))"#.into()).unwrap();
let s = super::silk_shapes(&b);
// the reference, its silk line, the board text; not the Fab value, the hidden property or a drawing line
assert_eq!(s.len(), 3, "{s:?}");
// the reference sits 2 mm "above" the footprint origin, turned 90: at (8, 10) on the board
let r = s.iter().find(|x| x.what().contains("U3 reference")).unwrap();
assert!(r.distance((8.0, 10.0)) == 0.0 && r.distance((10.0, 10.0)) > 0.5, "{r:?}");
// the text turned 90 degrees is taller than wide
if let super::Shape::Box { x0, y0, x1, y1, .. } = r { assert!(y1 - y0 > x1 - x0); }
let line = s.iter().find(|x| x.what() == "U3 line").unwrap();
assert!(line.distance((9.0, 10.0)) < 1e-9, "the line from (-1,-1) to (1,-1) turned 90 runs x = 9: {line:?}");
assert!(s.iter().any(|x| x.layer() == "B.SilkS" && x.distance((20.0, 20.0)) == 0.0));
assert!(super::near(&s, (25.0, 5.0), 0.2).is_none());
}
}