app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #308: kicad-lane: 1.0.65, live adds built from library text, compile, list_installed_libraries, replicate_layo…
ba7988a
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
//! Board items created live through KiCad's protobuf `CreateItems`. KiCad 10.0.x answers
//! `ParseAndCreateItemsFromString` with an empty reply and creates nothing (checked on 10.0.4 and
//! 10.0.7), so the blocks a board edit builds as KiCad text are encoded here as the protobuf items
//! `CreateItems` takes. A block this module cannot encode yet answers None, and the edit keeps the
//! text path (which refuses with live_create_unsupported on those KiCad versions).
//!
//! Encoded today:
//! - the board graphics `gr_line`, `gr_arc`, `gr_rect`, `gr_circle` and `gr_poly`
//! (`kiapi.board.types.BoardGraphicShape`) with their uuid, layer, net, stroke and fill;
//! - a `footprint` whose library footprint is already on the board on the same side: KiCad's own
//! encoding of that part is cloned (fresh ids for it and every child, the new pose, reference,
//! value, pad nets and user fields from the new block, no schematic link). A footprint the board
//! does not have yet keeps the text path.
use super::*;
use crate::placement::{distance_bytes, parse_fields, put_varint, vector2_bytes, wire_message, wire_varint};
use crate::schematic::{new_uuid, parse_node, Node};
use kicad_ipc_rs::FootprintItem;
pub const GRAPHIC_TYPE_URL: &str = "type.googleapis.com/kiapi.board.types.BoardGraphicShape";
fn nm(v: f64) -> i64 {
(v * 1e6).round() as i64
}
fn point(n: &Node, tag: &str) -> Result<(i64, i64), String> {
let p = n.child(tag).ok_or_else(|| format!("({tag} ..) missing"))?;
Ok((nm(p.num(0).ok_or("bad x")?), nm(p.num(1).ok_or("bad y")?)))
}
fn vec2(p: (i64, i64)) -> Vec<u8> {
vector2_bytes(p.0, p.1)
}
/// KiCad's `(stroke (type ..))` names to `StrokeLineStyle`.
fn stroke_style(n: &Node) -> u64 {
match n.child("stroke").and_then(|s| s.child_value("type")).as_deref() {
Some("dash") => 3,
Some("dot") => 4,
Some("dash_dot") => 5,
Some("dash_dot_dot") => 6,
Some("default") => 1,
_ => 2,
}
}
fn stroke_width(n: &Node) -> f64 {
n.child("stroke").and_then(|s| s.child("width")).and_then(|w| w.num(0)).or_else(|| n.child("width").and_then(|w| w.num(0))).unwrap_or(0.0)
}
/// `(fill yes|solid)` is filled; `no`, `none` or nothing is not.
fn filled(n: &Node) -> bool {
matches!(n.child_value("fill").as_deref(), Some("yes") | Some("solid"))
}
/// A `(pts ..)` list as `kiapi.common.types.PolyLine` (closed): points and start-mid-end arcs.
fn polyline(pts: &Node) -> Result<Vec<u8>, String> {
let mut line = Vec::new();
for c in pts.children().iter().skip(1) {
let mut node = Vec::new();
match c.head() {
Some("xy") => wire_message(&mut node, 1, &vec2((nm(c.num(0).ok_or("bad x")?), nm(c.num(1).ok_or("bad y")?)))),
Some("arc") => {
let mut arc = Vec::new();
wire_message(&mut arc, 1, &vec2(point(c, "start")?));
wire_message(&mut arc, 2, &vec2(point(c, "mid")?));
wire_message(&mut arc, 3, &vec2(point(c, "end")?));
wire_message(&mut node, 2, &arc);
}
other => return Err(format!("unknown point node {other:?}")),
}
wire_message(&mut line, 1, &node);
}
wire_varint(&mut line, 2, 1);
Ok(line)
}
/// One `gr_*` block as `kiapi.board.types.BoardGraphicShape` bytes. `layer_id` maps KiCad's
/// layer names to the API's ids.
pub fn graphic_bytes(n: &Node, layer_id: &dyn Fn(&str) -> Option<i32>) -> Result<Vec<u8>, String> {
let head = n.head().unwrap_or("");
let mut stroke = Vec::new();
wire_message(&mut stroke, 1, &distance_bytes(nm(stroke_width(n))));
wire_varint(&mut stroke, 2, stroke_style(n));
let mut fill = Vec::new();
wire_varint(&mut fill, 1, if filled(n) { 2 } else { 1 });
let mut attrs = Vec::new();
wire_message(&mut attrs, 1, &stroke);
wire_message(&mut attrs, 2, &fill);
let mut shape = Vec::new();
wire_message(&mut shape, 3, &attrs);
let mut geom = Vec::new();
let field = match head {
"gr_line" => {
wire_message(&mut geom, 1, &vec2(point(n, "start")?));
wire_message(&mut geom, 2, &vec2(point(n, "end")?));
4
}
"gr_rect" => {
wire_message(&mut geom, 1, &vec2(point(n, "start")?));
wire_message(&mut geom, 2, &vec2(point(n, "end")?));
5
}
"gr_arc" => {
wire_message(&mut geom, 1, &vec2(point(n, "start")?));
wire_message(&mut geom, 2, &vec2(point(n, "mid")?));
wire_message(&mut geom, 3, &vec2(point(n, "end")?));
6
}
"gr_circle" => {
wire_message(&mut geom, 1, &vec2(point(n, "center")?));
wire_message(&mut geom, 2, &vec2(point(n, "end")?));
7
}
"gr_poly" => {
let pts = n.child("pts").ok_or("(pts ..) missing")?;
let mut polygon = Vec::new();
wire_message(&mut polygon, 1, &polyline(pts)?);
wire_message(&mut geom, 1, &polygon);
8
}
other => return Err(format!("{other} is not a board graphic")),
};
wire_message(&mut shape, field, &geom);
let layer_name = n.child_value("layer").ok_or("(layer ..) missing")?;
let layer = layer_id(&layer_name).ok_or_else(|| format!("KiCad has no layer named {layer_name}"))?;
let mut out = Vec::new();
wire_message(&mut out, 1, &shape);
wire_varint(&mut out, 2, layer as u64);
if let Some(net) = n.child("net") {
// (net "GND") or the older (net 3 "GND")
let name = net.arg(1).or_else(|| net.arg(0)).filter(|s| s.parse::<i64>().is_err()).unwrap_or_default();
if !name.is_empty() {
let mut nb = Vec::new();
wire_message(&mut nb, 2, name.as_bytes());
wire_message(&mut out, 3, &nb);
}
}
if let Some(id) = n.child_value("uuid") {
let mut k = Vec::new();
wire_message(&mut k, 1, id.as_bytes());
wire_message(&mut out, 4, &k);
}
// LockedState: LS_UNLOCKED 1, LS_LOCKED 2
wire_varint(&mut out, 5, if n.child_value("locked").as_deref() == Some("yes") { 2 } else { 1 });
Ok(out)
}
// -- footprints, cloned from one already on the board ---------------------------------------------
enum Edit {
Keep,
Drop,
Bytes(Vec<u8>),
}
/// A message with some of its fields replaced or dropped (`f` decides per field); `extra` fields
/// (number, length-delimited bytes) are appended.
fn rewrite(msg: &[u8], f: &dyn Fn(u32, u8, &[u8]) -> Result<Edit, String>, extra: &[(u32, Vec<u8>)]) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(msg.len() + 64);
for fld in parse_fields(msg)? {
match f(fld.number, fld.wire, fld.data)? {
Edit::Drop => {}
Edit::Bytes(b) => wire_message(&mut out, fld.number, &b),
Edit::Keep => {
put_varint(&mut out, ((fld.number as u64) << 3) | fld.wire as u64);
if fld.wire == 2 {
put_varint(&mut out, fld.data.len() as u64);
}
out.extend_from_slice(fld.data);
}
}
}
for (n, b) in extra {
wire_message(&mut out, *n, b);
}
Ok(out)
}
fn kiid(id: &str) -> Vec<u8> {
let mut k = Vec::new();
wire_message(&mut k, 1, id.as_bytes());
k
}
fn string_field(msg: &[u8], number: u32) -> Option<String> {
parse_fields(msg).ok()?.into_iter().find(|f| f.number == number && f.wire == 2).and_then(|f| String::from_utf8(f.data.to_vec()).ok())
}
/// `BoardText` with a fresh id and, when given, new text (`Text.text`, field 5).
fn board_text(bt: &[u8], text: Option<&str>) -> Result<Vec<u8>, String> {
rewrite(bt, &|n, _w, d| match n {
1 => Ok(Edit::Bytes(kiid(&new_uuid()))),
2 => match text {
Some(t) => Ok(Edit::Bytes(rewrite(d, &|n2, _w2, _d2| Ok(if n2 == 5 { Edit::Bytes(t.as_bytes().to_vec()) } else { Edit::Keep }), &[])?)),
None => Ok(Edit::Keep),
},
_ => Ok(Edit::Keep),
}, &[])
}
/// One child of the template's definition for the new part, or None to leave it out (a user
/// field the new part does not carry).
fn clone_child(type_url: &str, bytes: &[u8], nets: &std::collections::HashMap<String, String>, props: &std::collections::HashMap<String, String>) -> Result<Option<Vec<u8>>, String> {
let name = type_url.rsplit('.').next().unwrap_or("");
match name {
"Pad" => {
let number = string_field(bytes, 3).unwrap_or_default();
let net = nets.get(&number).cloned();
let extra: Vec<(u32, Vec<u8>)> = net.iter().map(|n| {
let mut nb = Vec::new();
wire_message(&mut nb, 2, n.as_bytes());
(4, nb)
}).collect();
Ok(Some(rewrite(bytes, &|n, _w, _d| Ok(match n {
1 => Edit::Bytes(kiid(&new_uuid())),
4 => Edit::Drop,
_ => Edit::Keep,
}), &extra)?))
}
"Field" => {
// a user field: kept only when the new part has it, with the new part's value
let fname = string_field(bytes, 2).unwrap_or_default();
let Some(v) = props.get(&fname) else { return Ok(None) };
Ok(Some(rewrite(bytes, &|n, _w, d| Ok(if n == 3 { Edit::Bytes(board_text(d, Some(v))?) } else { Edit::Keep }), &[])?))
}
"BoardText" => Ok(Some(board_text(bytes, None)?)),
"BoardGraphicShape" => Ok(Some(rewrite(bytes, &|n, _w, _d| Ok(if n == 4 { Edit::Bytes(kiid(&new_uuid())) } else { Edit::Keep }), &[])?)),
"Footprint3DModel" => Ok(Some(bytes.to_vec())),
"BoardTextBox" | "Zone" | "Dimension" | "ReferenceImage" | "Barcode" => {
Ok(Some(rewrite(bytes, &|n, _w, _d| Ok(if n == 1 { Edit::Bytes(kiid(&new_uuid())) } else { Edit::Keep }), &[])?))
}
// a group lists its members by id; the fresh ids no longer match it
other => Err(format!("the template carries a {other} the clone cannot renumber")),
}
}
/// The library id `(footprint "Lib:Name" ...)` names, split.
fn lib_id(n: &Node) -> Option<(String, String)> {
let v = n.arg(0)?;
let (lib, name) = v.split_once(':')?;
Some((lib.to_string(), name.to_string()))
}
/// The template's library id and layer, to match a new block against.
pub fn template_key(fp: &FootprintItem) -> Option<(String, String, i32)> {
let p = fp.proto();
let id = p.definition.as_ref()?.id.as_ref()?;
Some((id.library_nickname.clone(), id.entry_name.clone(), p.layer))
}
fn round4(v: f64) -> String {
format!("{:.4}", v + 0.0)
}
/// What makes two placed copies of a footprint the same part, in board-file form: every pad
/// (number, kind, shape, local position, angle relative to the part, size, drill, layers, corner
/// ratio), the graphics per kind and layer, and the 3D models. Pose, ids, fields and nets differ.
fn definition_signature(fp: &Node) -> Vec<String> {
let fp_angle = fp.child("at").and_then(|a| a.num(2)).unwrap_or(0.0);
let mut sig = Vec::new();
for c in fp.children() {
match c.head() {
Some("pad") => {
let at = c.child("at");
let rel = (at.and_then(|a| a.num(2)).unwrap_or(0.0) - fp_angle).rem_euclid(360.0);
let num = |n: &str, i: usize| c.child(n).and_then(|x| x.num(i)).map(round4).unwrap_or_default();
sig.push(format!(
"pad {} {} {} at {} {} rot {} size {} {} drill {} {} layers {} rr {}",
c.arg(0).unwrap_or_default(), c.arg(1).unwrap_or_default(), c.arg(2).unwrap_or_default(),
num("at", 0), num("at", 1), round4(rel), num("size", 0), num("size", 1),
c.child("drill").and_then(|d| d.arg(0)).unwrap_or_default(), c.child("drill").and_then(|d| d.arg(1)).unwrap_or_default(),
c.child("layers").map(|l| (0..8).filter_map(|i| l.arg(i)).collect::<Vec<_>>().join(",")).unwrap_or_default(),
// KiCad writes the default corner ratio (0.25) out; a library may leave it out
if c.arg(2).as_deref() == Some("roundrect") && c.child("roundrect_rratio").is_none() { round4(0.25) } else { num("roundrect_rratio", 0) },
));
}
Some(h @ ("fp_line" | "fp_arc" | "fp_circle" | "fp_rect" | "fp_poly" | "fp_curve" | "fp_text" | "fp_text_box")) => {
sig.push(format!("{h} {}", c.child_value("layer").unwrap_or_default()));
}
Some("model") => sig.push(format!("model {}", c.arg(0).unwrap_or_default())),
_ => {}
}
}
sig.sort();
sig
}
/// A footprint on the board to clone for `block`: same library footprint, same side, the same
/// definition (definition_signature), and not one this change removes (an update from the library
/// must not bring the old copy back). Err says why none fits, for the reply.
pub fn find_template(block: &Node, templates: &[FootprintItem], board_text: &str, removed: &[String]) -> Result<FootprintItem, String> {
let (lib, name) = lib_id(block).ok_or("the new part names no library footprint")?;
let layer = BoardLayerInfo::id_from_name(&block.child_value("layer").unwrap_or_else(|| "F.Cu".into())).ok_or("unknown layer")?;
let want = definition_signature(block);
let board = crate::schematic::Document::parse(board_text).map_err(|e| e.to_string())?;
let same: Vec<&FootprintItem> = templates.iter().filter(|t| template_key(t) == Some((lib.clone(), name.clone(), layer))).collect();
if same.is_empty() {
return Err(format!("no {lib}:{name} on this side of the board to copy KiCad's own encoding from"));
}
let mut why = String::new();
for t in same {
let Some(id) = t.id() else { continue };
if removed.iter().any(|r| r == id) {
why = "the only copy is the footprint this edit replaces".into();
continue;
}
let Some(n) = board.root.children().iter().find(|c| c.head() == Some("footprint") && c.child_value("uuid").as_deref() == Some(id)) else { continue };
let have = definition_signature(n);
if have == want {
return Ok(t.clone());
}
let lib_only = want.iter().find(|l| !have.contains(l)).cloned().unwrap_or_default();
let board_only = have.iter().find(|l| !want.contains(l)).cloned().unwrap_or_default();
why = format!("{} on the board differs from the library footprint (library: {lib_only}; board: {board_only})", fp_reference(n));
}
Err(why)
}
fn fp_reference(n: &Node) -> String {
n.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")).and_then(|c| c.arg(1)).unwrap_or_else(|| "its copy".into())
}
/// A new footprint (`block`, the text the file edit would write) built from `template`, KiCad's own
/// encoding of the same library footprint already on the board.
pub fn clone_footprint(template: &FootprintItem, block: &Node) -> Result<FootprintItem, String> {
let at = block.child("at").ok_or("(at ..) missing")?;
let (x, y, rot) = (nm(at.num(0).ok_or("bad x")?), nm(at.num(1).ok_or("bad y")?), at.num(2).unwrap_or(0.0));
let reference = block.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")).and_then(|c| c.arg(1)).ok_or("the new part has no Reference")?;
let mut props = std::collections::HashMap::new();
for c in block.children().iter().filter(|c| c.head() == Some("property")) {
if let (Some(k), Some(v)) = (c.arg(0), c.arg(1)) {
props.insert(k, v);
}
}
let mut nets = std::collections::HashMap::new();
for pad in block.children().iter().filter(|c| c.head() == Some("pad")) {
if let (Some(num), Some(net)) = (pad.arg(0), pad.child("net")) {
if let Some(name) = net.arg(1).or_else(|| net.arg(0)).filter(|s| s.parse::<i64>().is_err()) {
nets.insert(num, name);
}
}
}
let mut fp = template.clone();
super::place_footprint_proto(&mut fp, (x, y, rot), &reference).map_err(|e| e.message)?;
let p = fp.proto_mut();
p.id = Some(Default::default());
if let Some(k) = p.id.as_mut() {
k.value = block.child_value("uuid").unwrap_or_else(new_uuid);
}
p.locked = 1;
p.symbol_path = None;
p.symbol_sheet_name.clear();
p.symbol_sheet_filename.clear();
for (field, key) in [(&mut p.reference_field, "Reference"), (&mut p.value_field, "Value"), (&mut p.datasheet_field, "Datasheet"), (&mut p.description_field, "Description")] {
if let Some(bt) = field.as_mut().and_then(|f| f.text.as_mut()) {
if let Some(k) = bt.id.as_mut() {
k.value = new_uuid();
}
if let (Some(t), Some(v)) = (bt.text.as_mut(), props.get(key)) {
t.text = v.clone();
}
}
}
if let Some(def) = p.definition.as_mut() {
let mut items = Vec::with_capacity(def.items.len());
for it in def.items.drain(..) {
if let Some(v) = clone_child(&it.type_url, &it.value, &nets, &props)? {
items.push(prost_types::Any { type_url: it.type_url, value: v });
}
}
def.items = items;
}
Ok(fp)
}
// -- footprints built from their own text (no copy on the board needed) ------------------------------
const CU_ALL: &[&str] = &["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "In5.Cu", "In6.Cu", "In7.Cu", "In8.Cu", "In9.Cu", "In10.Cu", "In11.Cu", "In12.Cu", "In13.Cu", "In14.Cu", "In15.Cu", "In16.Cu", "In17.Cu", "In18.Cu", "In19.Cu", "In20.Cu", "In21.Cu", "In22.Cu", "In23.Cu", "In24.Cu", "In25.Cu", "In26.Cu", "In27.Cu", "In28.Cu", "In29.Cu", "In30.Cu", "B.Cu"];
/// A pad's `(layers ..)` as API layer ids, wildcards expanded the way KiCad stores them
/// (`*.Cu` is every copper layer, F, In1..In30, B).
fn pad_layers(n: &Node) -> Result<Vec<i32>, String> {
let mut names: Vec<String> = Vec::new();
for l in n.child("layers").map(|l| l.children().iter().skip(1).filter_map(|c| c.value()).collect::<Vec<_>>()).unwrap_or_default() {
match l.as_str() {
"*.Cu" => names.extend(CU_ALL.iter().map(|s| s.to_string())),
"F&B.Cu" => names.extend(["F.Cu".to_string(), "B.Cu".to_string()]),
w if w.starts_with("*.") => {
let suf = &w[2..];
names.extend([format!("F.{suf}"), format!("B.{suf}")]);
}
other => names.push(other.to_string()),
}
}
let mut ids = Vec::new();
for n in names {
let id = BoardLayerInfo::id_from_name(&n).ok_or_else(|| format!("unknown pad layer {n}"))?;
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn mm(n: &Node, tag: &str, i: usize) -> Option<f64> {
n.child(tag).and_then(|c| c.num(i))
}
/// One `(pad ..)` of a placed footprint as KiCad's Pad item, at board coordinates.
fn pad_item(n: &Node, origin: (f64, f64), rot: f64) -> Result<prost_types::Any, String> {
for unsupported in ["primitives", "solder_mask_margin", "solder_paste_margin", "solder_paste_margin_ratio", "thermal_width", "thermal_gap", "thermal_bridge_width", "padstack", "teardrops"] {
if n.child(unsupported).is_some() {
return Err(format!("pad {} carries {unsupported}", n.arg(0).unwrap_or_default()));
}
}
let kind = n.arg(1).unwrap_or_default();
let shape = n.arg(2).unwrap_or_default();
let at = n.child("at").ok_or("pad without (at ..)")?;
let local = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0));
let angle = at.num(2).unwrap_or(0.0);
let (x, y) = crate::placement::to_board(origin, rot, local);
let mut pad = kicad_ipc_rs::PadItem::from_proto(Default::default());
let p = pad.proto_mut();
p.id = Some(Default::default());
if let Some(k) = p.id.as_mut() {
k.value = n.child_value("uuid").unwrap_or_else(new_uuid);
}
p.locked = 1;
p.number = n.arg(0).unwrap_or_default();
p.r#type = match kind.as_str() {
"thru_hole" => 1,
"smd" => 2,
"connect" => 3,
"np_thru_hole" => 4,
other => return Err(format!("pad type {other}")),
};
p.net = Some(Default::default());
if let (Some(net), Some(nn)) = (n.child("net"), p.net.as_mut()) {
nn.name = net.arg(1).or_else(|| net.arg(0)).filter(|s| s.parse::<i64>().is_err()).unwrap_or_default();
}
p.position = Some(Default::default());
if let Some(v) = p.position.as_mut() {
v.x_nm = nm(x);
v.y_nm = nm(y);
}
if let Some(c) = mm(n, "clearance", 0) {
p.copper_clearance_override = Some(Default::default());
if let Some(d) = p.copper_clearance_override.as_mut() {
d.value_nm = nm(c);
}
}
p.pad_to_die_length = Some(Default::default());
if let Some(d) = p.pad_to_die_length.as_mut() {
d.value_nm = nm(mm(n, "die_length", 0).unwrap_or(0.0));
}
p.symbol_pin = Some(Default::default());
if let Some(sp) = p.symbol_pin.as_mut() {
sp.name = n.child_value("pinfunction").unwrap_or_default();
}
p.pad_stack = Some(Default::default());
let ps = p.pad_stack.as_mut().ok_or("pad stack")?;
ps.r#type = 1;
ps.layers = pad_layers(n)?;
ps.unconnected_layer_removal = 1;
ps.angle = Some(Default::default());
if let Some(a) = ps.angle.as_mut() {
a.value_degrees = angle;
}
let (f_cu, b_cu) = (BoardLayerInfo::id_from_name("F.Cu").unwrap_or(3), BoardLayerInfo::id_from_name("B.Cu").unwrap_or(34));
ps.drill = Some(Default::default());
if let Some(d) = ps.drill.as_mut() {
d.start_layer = f_cu;
d.end_layer = b_cu;
d.diameter = Some(Default::default());
d.shape = 1;
if let (Some(dr), Some(dia)) = (n.child("drill"), d.diameter.as_mut()) {
if dr.arg(0).as_deref() == Some("oval") {
d.shape = 2;
dia.x_nm = nm(dr.num(1).unwrap_or(0.0));
dia.y_nm = nm(dr.num(2).or(dr.num(1)).unwrap_or(0.0));
} else {
let v = dr.num(0).unwrap_or(0.0);
dia.x_nm = nm(v);
dia.y_nm = nm(v);
}
}
}
ps.copper_layers.push(Default::default());
let cl = ps.copper_layers.last_mut().ok_or("copper layer")?;
cl.layer = f_cu;
let chamfered = n.child("chamfer").is_some();
cl.shape = match shape.as_str() {
"circle" => 1,
"rect" => 2,
"oval" => 3,
"trapezoid" => 4,
"roundrect" if chamfered => 6,
"roundrect" => 5,
other => return Err(format!("pad shape {other}")),
};
cl.size = Some(Default::default());
if let Some(v) = cl.size.as_mut() {
v.x_nm = nm(mm(n, "size", 0).unwrap_or(0.0));
v.y_nm = nm(mm(n, "size", 1).unwrap_or(0.0));
}
cl.corner_rounding_ratio = mm(n, "roundrect_rratio", 0).unwrap_or(0.25);
cl.chamfer_ratio = mm(n, "chamfer_ratio", 0).unwrap_or(0.2);
if let Some(ch) = n.child("chamfer") {
let corners: Vec<String> = ch.children().iter().skip(1).filter_map(|c| c.value()).collect();
cl.chamfered_corners = Some(Default::default());
if let Some(c) = cl.chamfered_corners.as_mut() {
c.top_left = corners.iter().any(|x| x == "top_left");
c.top_right = corners.iter().any(|x| x == "top_right");
c.bottom_left = corners.iter().any(|x| x == "bottom_left");
c.bottom_right = corners.iter().any(|x| x == "bottom_right");
}
}
cl.custom_anchor_shape = 1;
cl.offset = Some(Default::default());
if let (Some(off), Some(v)) = (n.child("drill").and_then(|d| d.child("offset")), cl.offset.as_mut()) {
v.x_nm = nm(off.num(0).unwrap_or(0.0));
v.y_nm = nm(off.num(1).unwrap_or(0.0));
}
if let Some(rd) = n.child("rect_delta") {
cl.trapezoid_delta = Some(Default::default());
if let Some(v) = cl.trapezoid_delta.as_mut() {
v.x_nm = nm(rd.num(0).unwrap_or(0.0));
v.y_nm = nm(rd.num(1).unwrap_or(0.0));
}
}
ps.zone_settings = Some(Default::default());
if let Some(z) = ps.zone_settings.as_mut() {
// (zone_connect 0 none | 1 thermal | 2 full | 3 thermal on PTH only), else the parent's
z.zone_connection = match n.child("zone_connect").and_then(|c| c.num(0)).map(|v| v as i64) {
Some(0) => 2,
Some(1) => 3,
Some(2) => 4,
Some(3) => 5,
_ => 1,
};
z.thermal_spokes = Some(Default::default());
if let Some(t) = z.thermal_spokes.as_mut() {
t.angle = Some(Default::default());
if let Some(a) = t.angle.as_mut() {
a.value_degrees = mm(n, "thermal_bridge_angle", 0).unwrap_or(if matches!(shape.as_str(), "circle" | "oval") { 45.0 } else { 90.0 });
}
}
}
Ok(EditablePcbItem::Pad(pad).into_any())
}
fn set_text(t: &mut kicad_ipc_rs::BoardTextItem, n: &Node, value: &str, at: (f64, f64), angle: f64, layer: i32) {
let p = t.proto_mut();
p.id = Some(Default::default());
if let Some(k) = p.id.as_mut() {
k.value = n.child_value("uuid").unwrap_or_else(new_uuid);
}
p.layer = layer;
p.locked = 1;
p.knockout = n.child("layer").map(|l| l.children().iter().any(|c| c.raw() == Some("knockout"))).unwrap_or(false);
p.text = Some(Default::default());
let Some(tx) = p.text.as_mut() else { return };
tx.text = value.to_string();
tx.position = Some(Default::default());
if let Some(v) = tx.position.as_mut() {
v.x_nm = nm(at.0);
v.y_nm = nm(at.1);
}
tx.attributes = Some(Default::default());
let Some(a) = tx.attributes.as_mut() else { return };
let effects = n.child("effects");
let font = effects.and_then(|e| e.child("font"));
let size = font.and_then(|f| f.child("size"));
a.size = Some(Default::default());
if let Some(v) = a.size.as_mut() {
// (size height width)
v.y_nm = nm(size.and_then(|s| s.num(0)).unwrap_or(1.0));
v.x_nm = nm(size.and_then(|s| s.num(1)).unwrap_or(1.0));
}
a.stroke_width = Some(Default::default());
if let Some(d) = a.stroke_width.as_mut() {
d.value_nm = nm(font.and_then(|f| f.child("thickness")).and_then(|t| t.num(0)).unwrap_or(0.15));
}
a.bold = font.and_then(|f| f.child_value("bold")).as_deref() == Some("yes");
a.italic = font.and_then(|f| f.child_value("italic")).as_deref() == Some("yes");
let justify: Vec<String> = effects.and_then(|e| e.child("justify")).map(|j| j.children().iter().skip(1).filter_map(|c| c.value()).collect()).unwrap_or_default();
a.horizontal_alignment = if justify.iter().any(|j| j == "left") { 1 } else if justify.iter().any(|j| j == "right") { 3 } else { 2 };
a.vertical_alignment = if justify.iter().any(|j| j == "top") { 1 } else if justify.iter().any(|j| j == "bottom") { 3 } else { 2 };
a.mirrored = justify.iter().any(|j| j == "mirror");
a.angle = Some(Default::default());
if let Some(an) = a.angle.as_mut() {
an.value_degrees = angle;
}
a.line_spacing = 1.0;
a.visible = true;
a.multiline = true;
a.keep_upright = true;
}
/// `fp_*` graphic of a placed footprint as the board graphic KiCad's API takes (board coordinates).
fn fp_graphic(n: &Node, origin: (f64, f64), rot: f64, layer_id: &dyn Fn(&str) -> Option<i32>) -> Result<Vec<u8>, String> {
let head = n.head().unwrap_or("").replacen("fp_", "gr_", 1);
let xf = |p: (f64, f64)| crate::placement::to_board(origin, rot, p);
let pt = |tag: &str| -> Option<(f64, f64)> { let c = n.child(tag)?; Some((c.num(0)?, c.num(1)?)) };
let fmt = |p: (f64, f64)| format!("{} {}", p.0, p.1);
let tail = format!("(stroke (width {}) (type {})) (fill {}) (layer \"{}\"){}",
stroke_width(n), n.child("stroke").and_then(|s| s.child_value("type")).unwrap_or_else(|| "solid".into()),
if filled(n) { "yes" } else { "no" }, n.child_value("layer").unwrap_or_default(),
n.child_value("uuid").map(|u| format!(" (uuid \"{u}\")")).unwrap_or_default());
let text = match head.as_str() {
"gr_line" => format!("(gr_line (start {}) (end {}) {tail})", fmt(xf(pt("start").ok_or("start")?)), fmt(xf(pt("end").ok_or("end")?))),
"gr_arc" => format!("(gr_arc (start {}) (mid {}) (end {}) {tail})", fmt(xf(pt("start").ok_or("start")?)), fmt(xf(pt("mid").ok_or("mid")?)), fmt(xf(pt("end").ok_or("end")?))),
"gr_circle" => format!("(gr_circle (center {}) (end {}) {tail})", fmt(xf(pt("center").ok_or("center")?)), fmt(xf(pt("end").ok_or("end")?))),
"gr_rect" => {
let (a, b) = (pt("start").ok_or("start")?, pt("end").ok_or("end")?);
let corners = [a, (b.0, a.1), b, (a.0, b.1)].map(xf);
if (rot.rem_euclid(90.0)).abs() < 1e-9 {
// the library's own two corners, turned: KiCad turns them back to exactly the library's
format!("(gr_rect (start {}) (end {}) {tail})", fmt(xf(a)), fmt(xf(b)))
} else {
format!("(gr_poly (pts {}) {tail})", corners.iter().map(|c| format!("(xy {})", fmt(*c))).collect::<Vec<_>>().join(" "))
}
}
"gr_poly" => {
let pts = n.child("pts").ok_or("pts")?;
let mut out = Vec::new();
for c in pts.children().iter().skip(1) {
match c.head() {
Some("xy") => out.push(format!("(xy {})", fmt(xf((c.num(0).unwrap_or(0.0), c.num(1).unwrap_or(0.0)))))),
Some("arc") => {
let g = |t: &str| c.child(t).and_then(|x| Some((x.num(0)?, x.num(1)?))).map(xf).map(fmt).unwrap_or_default();
out.push(format!("(arc (start {}) (mid {}) (end {}))", g("start"), g("mid"), g("end")));
}
_ => {}
}
}
format!("(gr_poly (pts {}) {tail})", out.join(" "))
}
other => return Err(format!("{} cannot be encoded", other.replacen("gr_", "fp_", 1))),
};
graphic_bytes(&parse_node(&text)?, layer_id)
}
/// A `(model ..)` as `kiapi.board.types.Footprint3DModel` bytes (mm offset, degrees, scale).
fn model_bytes(n: &Node) -> Vec<u8> {
let xyz = |tag: &str, default: f64| -> Vec<u8> {
let c = n.child(tag).and_then(|t| t.child("xyz"));
let mut b = Vec::new();
for (i, f) in [1u32, 2, 3].iter().enumerate() {
let v = c.and_then(|c| c.num(i)).unwrap_or(default);
if v != 0.0 {
put_varint(&mut b, ((*f as u64) << 3) | 1);
b.extend_from_slice(&v.to_le_bytes());
}
}
b
};
let mut out = Vec::new();
wire_message(&mut out, 1, n.arg(0).unwrap_or_default().as_bytes());
wire_message(&mut out, 2, &xyz("scale", 1.0));
wire_message(&mut out, 3, &xyz("rotate", 0.0));
wire_message(&mut out, 4, &xyz("offset", 0.0));
wire_varint(&mut out, 5, if n.child_value("hide").as_deref() == Some("yes") { 0 } else { 1 });
let opacity = n.child("opacity").and_then(|o| o.num(0)).unwrap_or(1.0);
put_varint(&mut out, (6 << 3) | 1);
out.extend_from_slice(&opacity.to_le_bytes());
out
}
/// A footprint built from its own text (the block the closed-board edit would write), for a board
/// that has no copy of it. Refuses, so the edit keeps the text path, anything it cannot encode
/// faithfully: custom pad shapes, mask and paste margins, zones and groups inside the footprint.
pub fn build_footprint(block: &Node) -> Result<FootprintItem, String> {
let layer_id = |name: &str| BoardLayerInfo::id_from_name(name);
let at = block.child("at").ok_or("(at ..) missing")?;
let (x, y, rot) = (at.num(0).ok_or("bad x")?, at.num(1).ok_or("bad y")?, at.num(2).unwrap_or(0.0));
let side = block.child_value("layer").unwrap_or_else(|| "F.Cu".into());
let (lib, name) = lib_id(block).ok_or("the footprint names no library footprint")?;
let mut fp = FootprintItem::from_proto(Default::default());
let mut items: Vec<prost_types::Any> = Vec::new();
{
let p = fp.proto_mut();
p.id = Some(Default::default());
if let Some(k) = p.id.as_mut() {
k.value = block.child_value("uuid").unwrap_or_else(new_uuid);
}
p.position = Some(Default::default());
if let Some(v) = p.position.as_mut() {
v.x_nm = nm(x);
v.y_nm = nm(y);
}
p.orientation = Some(Default::default());
if let Some(o) = p.orientation.as_mut() {
o.value_degrees = rot;
}
p.layer = layer_id(&side).ok_or_else(|| format!("unknown side {side}"))?;
p.locked = if block.child("locked").is_some() { 2 } else { 1 };
p.definition = Some(Default::default());
let d = p.definition.as_mut().ok_or("definition")?;
d.id = Some(Default::default());
if let Some(i) = d.id.as_mut() {
i.library_nickname = lib;
i.entry_name = name;
}
let attr: Vec<String> = block.child("attr").map(|a| a.children().iter().skip(1).filter_map(|c| c.value()).collect()).unwrap_or_default();
d.attributes = Some(Default::default());
if let Some(a) = d.attributes.as_mut() {
a.description = block.child_value("descr").unwrap_or_default();
a.keywords = block.child_value("tags").unwrap_or_default();
a.not_in_schematic = attr.iter().any(|x| x == "board_only");
a.exclude_from_position_files = attr.iter().any(|x| x == "exclude_from_pos_files");
a.exclude_from_bill_of_materials = attr.iter().any(|x| x == "exclude_from_bom");
a.exempt_from_courtyard_requirement = attr.iter().any(|x| x == "allow_missing_courtyard");
a.do_not_populate = attr.iter().any(|x| x == "dnp");
a.allow_soldermask_bridges = attr.iter().any(|x| x == "allow_soldermask_bridges");
a.mounting_style = if attr.iter().any(|x| x == "smd") { 2 } else if attr.iter().any(|x| x == "through_hole") { 1 } else { 3 };
}
p.attributes = d.attributes.clone();
}
for c in block.children().iter().skip(1) {
match c.head() {
Some("property") => {
let key = c.arg(0).unwrap_or_default();
let value = c.arg(1).unwrap_or_default();
let fat = c.child("at");
let local = (fat.and_then(|a| a.num(0)).unwrap_or(0.0), fat.and_then(|a| a.num(1)).unwrap_or(0.0));
let pos = crate::placement::to_board((x, y), rot, local);
let lay = layer_id(&c.child_value("layer").unwrap_or_else(|| "F.Fab".into())).ok_or("unknown field layer")?;
let mut bt = kicad_ipc_rs::BoardTextItem::from_proto(Default::default());
set_text(&mut bt, c, &value, pos, fat.and_then(|a| a.num(2)).unwrap_or(0.0), lay);
let hidden = c.child_value("hide").as_deref() == Some("yes");
let fid = match key.as_str() { "Reference" => 1, "Value" => 2, "Footprint" => 3, "Datasheet" => 4, "Description" => 5, _ => 0 };
let mut field = kicad_ipc_rs::FieldItem::from_proto(Default::default());
{
let f = field.proto_mut();
f.id = Some(Default::default());
if let Some(i) = f.id.as_mut() {
i.id = fid;
}
f.name = key.clone();
f.text = Some(bt.into_proto());
f.visible = !hidden;
}
let p = fp.proto_mut();
match fid {
1 => p.reference_field = Some(field.into_proto()),
2 => p.value_field = Some(field.into_proto()),
4 => p.datasheet_field = Some(field.into_proto()),
5 => p.description_field = Some(field.into_proto()),
3 => {}
_ => items.push(EditablePcbItem::Field(field).into_any()),
}
}
Some("pad") => items.push(pad_item(c, (x, y), rot)?),
Some(h) if h.starts_with("fp_") && h != "fp_text" && h != "fp_text_box" => {
items.push(prost_types::Any { type_url: GRAPHIC_TYPE_URL.into(), value: fp_graphic(c, (x, y), rot, &layer_id)? });
}
Some("fp_text") => {
let fat = c.child("at");
let local = (fat.and_then(|a| a.num(0)).unwrap_or(0.0), fat.and_then(|a| a.num(1)).unwrap_or(0.0));
let pos = crate::placement::to_board((x, y), rot, local);
let lay = layer_id(&c.child_value("layer").unwrap_or_default()).ok_or("unknown text layer")?;
let mut bt = kicad_ipc_rs::BoardTextItem::from_proto(Default::default());
set_text(&mut bt, c, &c.arg(1).unwrap_or_default(), pos, fat.and_then(|a| a.num(2)).unwrap_or(0.0), lay);
items.push(EditablePcbItem::BoardText(bt).into_any());
}
Some("model") => items.push(prost_types::Any { type_url: "type.googleapis.com/kiapi.board.types.Footprint3DModel".into(), value: model_bytes(c) }),
Some(h @ ("zone" | "group" | "fp_text_box" | "dimension" | "net_tie_pad_groups" | "private_layers" | "embedded_files" | "component_classes")) => {
return Err(format!("the footprint carries a {h} the bridge cannot build through KiCad's API yet"));
}
_ => {}
}
}
if let Some(d) = fp.proto_mut().definition.as_mut() {
d.items = items;
}
Ok(fp)
}
/// Every block of a change as `CreateItems` payloads, or None when any block is of a kind this
/// module does not encode (the caller then keeps the text path for the whole change).
/// `template` finds a footprint on the board with the block's library id on the same side.
pub fn encode_blocks(blocks: &[String], template: &dyn Fn(&Node) -> Result<FootprintItem, String>) -> Encoded {
let layer_id = |name: &str| BoardLayerInfo::id_from_name(name);
let mut out = Vec::with_capacity(blocks.len());
for b in blocks {
let Ok(n) = parse_node(b.trim()) else { return Encoded::TextPath("a block KiCad's text parser must read".into()) };
match n.head() {
Some("gr_line" | "gr_arc" | "gr_rect" | "gr_circle" | "gr_poly") => match graphic_bytes(&n, &layer_id) {
Ok(v) => out.push(prost_types::Any { type_url: GRAPHIC_TYPE_URL.into(), value: v }),
Err(e) => return Encoded::Failed(e),
},
Some("footprint") => {
let fp = match template(&n) {
Ok(t) => clone_footprint(&t, &n),
// no copy on the board: build it from its own text
Err(why) => match build_footprint(&n) {
Ok(fp) => Ok(fp),
Err(e) => return Encoded::TextPath(format!("{why}, and it cannot be built from its text ({e})")),
},
};
match fp {
Ok(fp) => out.push(EditablePcbItem::Footprint(fp).into_any()),
Err(e) => return Encoded::Failed(e),
}
}
other => return Encoded::TextPath(format!("a {} the bridge cannot build through KiCad's API yet", other.unwrap_or("block"))),
}
}
Encoded::Items(out)
}
/// What encode_blocks made of a change.
pub enum Encoded {
/// every block, as CreateItems payloads
Items(Vec<prost_types::Any>),
/// a block this module cannot encode, and why: the change keeps the text path
TextPath(String),
/// a block that should have encoded and did not
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::placement::parse_fields;
fn field<'a>(b: &'a [u8], n: u32) -> Option<&'a [u8]> {
parse_fields(b).ok()?.into_iter().find(|f| f.number == n).map(|f| f.data)
}
#[test]
fn a_filled_svg_polygon_on_copper_keeps_its_net_and_id() {
let n = parse_node("(gr_poly (pts (xy 1 2) (xy 3 2) (arc (start 3 2) (mid 4 3) (end 3 4))) (stroke (width 0) (type solid)) (fill yes) (layer \"F.Cu\") (net \"GND\") (uuid \"abc\"))").unwrap();
let b = graphic_bytes(&n, &|l| (l == "F.Cu").then_some(3)).unwrap();
let shape = field(&b, 1).unwrap();
let fill = field(field(shape, 3).unwrap(), 2).unwrap();
assert_eq!(fill, &[8, 2], "filled");
let poly = field(field(shape, 8).unwrap(), 1).unwrap();
let line = field(poly, 1).unwrap();
assert_eq!(parse_fields(line).unwrap().iter().filter(|f| f.number == 1).count(), 3, "two points and an arc");
assert_eq!(field(field(&b, 3).unwrap(), 2).unwrap(), b"GND");
assert_eq!(field(field(&b, 4).unwrap(), 1).unwrap(), b"abc");
}
#[test]
fn outline_shapes_encode_and_unknown_blocks_keep_the_text_path() {
let blocks: Vec<String> = ["(gr_line (start 0 0) (end 10 0) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"a\"))",
"(gr_arc (start 10 0) (mid 12 2) (end 10 4) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"b\"))",
"(gr_rect (start 0 0) (end 5 5) (stroke (width 0.1) (type solid)) (fill no) (layer \"Edge.Cuts\") (uuid \"c\"))",
"(gr_circle (center 2 2) (end 3 2) (stroke (width 0.1) (type solid)) (fill no) (layer \"Edge.Cuts\") (uuid \"d\"))"]
.iter().map(|s| s.to_string()).collect();
let Encoded::Items(anys) = encode_blocks(&blocks, &|_| Err("none".into())) else { panic!("graphics encode") };
assert_eq!(anys.len(), 4);
assert!(anys.iter().all(|a| a.type_url == GRAPHIC_TYPE_URL));
let mut with_fp = blocks.clone();
with_fp.push("(footprint \"X:Y\" (at 1 1) (layer \"F.Cu\"))".into());
assert!(matches!(encode_blocks(&with_fp, &|_| Err("none".into())), Encoded::Items(_)), "a footprint the board does not have is built from its text");
let mut with_zone = blocks.clone();
with_zone.push("(footprint \"X:Y\" (at 1 1) (layer \"F.Cu\") (zone (net 0)))".into());
assert!(matches!(encode_blocks(&with_zone, &|_| Err("none".into())), Encoded::TextPath(_)), "a footprint with a zone keeps the text path");
}
#[test]
fn a_library_block_matches_the_same_footprint_on_the_board_at_any_rotation() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route");
let board = std::fs::read_to_string(dir.join("route-fixture.kicad_pcb")).unwrap();
let lib = std::fs::read_to_string(dir.join("Fixture.pretty/R_0603_1608Metric.kicad_mod")).unwrap();
let doc = crate::schematic::Document::parse(&board).unwrap();
let r1 = doc.root.children().iter().find(|c| c.head() == Some("footprint") && c.arg(0).as_deref() == Some("Fixture:R_0603_1608Metric")).unwrap();
for rot in [0, 90, 180] {
let c = crate::board_edit::add_footprint(&board, Some((&lib, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "rotation": rot, "reference": "R", "value": "4k7"})).unwrap();
let block = parse_node(c.create[0].trim()).unwrap();
assert_eq!(definition_signature(&block), definition_signature(r1), "rotation {rot}");
}
// a library that changed since the part was placed is not the board's copy
let bigger = lib.replacen("(size 0.8 0.95)", "(size 0.9 0.95)", 1);
assert_ne!(bigger, lib, "the fixture's pad size changed");
let c = crate::board_edit::add_footprint(&board, Some((&bigger, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "reference": "R"})).unwrap();
assert_ne!(definition_signature(&parse_node(c.create[0].trim()).unwrap()), definition_signature(r1));
}
#[test]
fn a_library_footprint_builds_with_pads_at_board_positions() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route");
let board = std::fs::read_to_string(dir.join("route-fixture.kicad_pcb")).unwrap();
let lib = std::fs::read_to_string(dir.join("Fixture.pretty/R_0603_1608Metric.kicad_mod")).unwrap();
let c = crate::board_edit::add_footprint(&board, Some((&lib, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "rotation": 90, "reference": "R", "value": "4k7"})).unwrap();
let block = parse_node(c.create[0].trim()).unwrap();
let fp = build_footprint(&block).unwrap();
let p = fp.proto();
assert_eq!(p.position.as_ref().map(|v| (v.x_nm, v.y_nm)), Some((40_000_000, 30_000_000)));
assert_eq!(p.value_field.as_ref().and_then(|f| f.text.as_ref()).and_then(|t| t.text.as_ref()).map(|t| t.text.clone()), Some("4k7".into()));
let pads: Vec<_> = p.definition.as_ref().unwrap().items.iter().filter(|a| a.type_url.ends_with(".Pad")).map(|a| EditablePcbItem::from_any(a.clone()).unwrap()).collect();
assert_eq!(pads.len(), 2);
// pad 1 sits at local (-0.825, 0): turned 90 degrees about (40, 30) it lands at (40, 30.825)
let pos: Vec<(i64, i64)> = pads.iter().filter_map(|e| if let EditablePcbItem::Pad(pd) = e { pd.proto().position.as_ref().map(|v| (v.x_nm, v.y_nm)) } else { None }).collect();
assert!(pos.contains(&(40_000_000, 30_825_000)) && pos.contains(&(40_000_000, 29_175_000)), "{pos:?}");
let ids: std::collections::HashSet<String> = pads.iter().filter_map(|e| e.id().map(String::from)).collect();
assert_eq!(ids.len(), 2, "every pad has its own id");
}
}