app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
Colby Knox
Merge PR #415: Report a library Fusion refuses instead of calling it opened
73b4f61
7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263
//! The `.lbr` engine. Phase 2 ports `library_bundle.py` and the binding injection from
//! server.py here, behind golden-file tests: the same input library must produce the same
//! answer the Python bridge gives today (`tests/golden/*.json` are generated by the Python
//! functions themselves, on the fixtures in `tests/fixtures/`).
//!
//! AI hint: a golden that disagrees with Python is a bug HERE until proven otherwise; the
//! Python bridge is the production oracle for the whole port.
pub mod authoring;
pub mod import;
use std::collections::BTreeMap;
#[derive(Debug, thiserror::Error)]
pub enum LbrError {
#[error("not an EAGLE library: {0}")]
NotALibrary(String),
/// Phase 2 lands the engine; until then callers get an error, never a panic.
#[error("not ported to the Rust engine yet: {0}")]
NotPorted(&'static str),
}
/// Names that exist in a library (the equivalent of `_library_artifact_names`).
///
/// A deviceset's symbol and footprint are NOT required to share its name (the wiki's
/// ABM8-16MHZ sits on symbol+package "Crystal_GND24"), so `dev_sym` / `dev_pac` map each
/// deviceset to the gate symbols and device packages it actually references: a prove step
/// that EDITs `<deviceset>.sym` must follow the gate, not guess the name.
#[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ArtifactNames {
pub sym: Vec<String>,
pub pac: Vec<String>,
pub dev: Vec<String>,
pub p3d: Vec<String>,
pub dev_sym: BTreeMap<String, Vec<String>>,
pub dev_pac: BTreeMap<String, Vec<String>>,
}
pub fn artifact_names(xml: &str) -> Result<ArtifactNames, LbrError> {
// EAGLE files open with <!DOCTYPE eagle SYSTEM "eagle.dtd">; roxmltree refuses DTDs unless told.
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
let root = doc.root_element();
if root.tag_name().name() != "eagle" {
return Err(LbrError::NotALibrary(format!("root element is <{}>, not <eagle>", root.tag_name().name())));
}
let names = |tag: &str| -> Vec<String> {
root.descendants().filter(|n| n.is_element() && n.tag_name().name() == tag)
.filter_map(|n| n.attribute("name").map(str::to_string)).collect()
};
let mut out = ArtifactNames { sym: names("symbol"), pac: names("package"), dev: names("deviceset"), p3d: names("package3d"), ..Default::default() };
for ds in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "deviceset") {
let Some(dn) = ds.attribute("name") else { continue };
let mut syms: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "gate")
.filter_map(|g| g.attribute("symbol").map(str::to_string)).collect();
syms.sort(); syms.dedup();
let mut pacs: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "device")
.filter_map(|d| d.attribute("package").map(str::to_string)).collect();
pacs.sort(); pacs.dedup();
out.dev_sym.insert(dn.to_string(), syms);
out.dev_pac.insert(dn.to_string(), pacs);
}
Ok(out)
}
/// One package's 3D binding: the urn Fusion resolves for `<package name=…>`.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Binding {
pub package: String,
pub wip_urn: String,
}
/// Inject EAGLE `<packages3d>` + per-device `<package3dinstances>` into an .lbr, MERGE-AWARE
/// and IDEMPOTENT (safe to re-run with any subset of parts). Bindings already in the file
/// are read first and new ones win on conflict; every prior 3D block is stripped and the
/// full merged set re-emitted, so a re-run with only the parts that failed last time
/// ACCUMULATES instead of wiping the parts that already succeeded. Byte-for-byte the
/// Python bridge's `_inject_package3d_bindings` (golden-tested), including its one quirk:
/// each re-run leaves one more blank line before `<symbols>` (harmless to Fusion).
///
/// AI hint: this is text surgery, not XML, on purpose: Fusion writes this markup in exactly
/// this shape and position, and re-serialising the whole document through an XML library
/// would move attributes and whitespace Fusion is sensitive to.
pub fn inject_package3d_bindings(lbr_text: &str, bindings: &[Binding]) -> String {
use regex::Regex;
// insertion order matters (the emitted block order is the merged map's order)
let mut merged: Vec<(String, String)> = Vec::new();
let mut upsert = |pkg: &str, urn: &str| {
if let Some(e) = merged.iter_mut().find(|(p, _)| p == pkg) { e.1 = urn.to_string(); }
else { merged.push((pkg.to_string(), urn.to_string())); }
};
let existing = Regex::new(r"(?s)<packages3d>(.*?)</packages3d>").unwrap();
let one = Regex::new(r#"<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)""#).unwrap();
if let Some(em) = existing.captures(lbr_text) {
for pm in one.captures_iter(em.get(1).map(|m| m.as_str()).unwrap_or("")) { upsert(&pm[1], &pm[2]); }
}
for b in bindings { upsert(&b.package, &b.wip_urn); }
let mut text = Regex::new(r"(?s)\s*<packages3d>.*?</packages3d>").unwrap().replace_all(lbr_text, "").to_string();
text = Regex::new(r"(?s)\s*<package3dinstances>.*?</package3dinstances>").unwrap().replace_all(&text, "").to_string();
let blocks: Vec<String> = merged.iter().map(|(pkg, urn)| format!(
r#"<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model"><description>{pkg}</description><packageinstances><packageinstance name="{pkg}"/></packageinstances></package3d>"#)).collect();
let pkgs3d = format!("<packages3d>\n{}\n</packages3d>\n", blocks.join("\n"));
text = text.replacen("</packages>", &format!("</packages>\n{pkgs3d}"), 1);
let dev_re = Regex::new(r"(?s)<device\b[^>]*>.*?</device>").unwrap();
let pkg_attr = Regex::new(r#"package="([^"]+)""#).unwrap();
dev_re.replace_all(&text, |m: ®ex::Captures| {
let dev = m.get(0).unwrap().as_str();
if let Some(pm) = pkg_attr.captures(dev) {
if let Some((_, urn)) = merged.iter().find(|(p, _)| p == &pm[1]) {
if dev.contains("</connects>") {
let inst = format!(r#"<package3dinstances><package3dinstance package3d_urn="{urn}"/></package3dinstances>"#);
return dev.replacen("</connects>", &format!("</connects>\n{inst}"), 1);
}
}
}
dev.to_string()
}).to_string()
}
// ───────────────────────── bundle pairing (library_bundle.py) ─────────────────────────
/// One orderable variant of a deviceset (a `<device>` × `<technology>`), with its attributes.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Variant {
#[serde(default)] pub device: String,
#[serde(default)] pub technology: String,
#[serde(default, rename = "fullName")] pub full_name: String,
#[serde(default)] pub package: String,
#[serde(default)] pub attributes: BTreeMap<String, String>,
#[serde(default)] pub missing: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Deviceset {
#[serde(default)] pub name: String,
#[serde(default)] pub description: String,
#[serde(default)] pub variants: Vec<Variant>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Totals {
pub devicesets: usize,
pub variants: usize,
#[serde(rename = "withMpn")] pub with_mpn: usize,
#[serde(rename = "withDistributor")] pub with_distributor: usize,
}
/// `_library_part_metadata`: every orderable variant in a .lbr with its attributes.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LibraryMetadata {
#[serde(default)] pub ok: bool,
#[serde(default)] pub devicesets: Vec<Deviceset>,
#[serde(default)] pub totals: Totals,
}
pub const DISTRIBUTOR_ATTRS: [&str; 3] = ["MOUSER_PN", "DIGIKEY_PN", "LCSC_PN"];
/// Offline, no Fusion: what an EE needs to ORDER each variant, and what is missing.
pub fn library_part_metadata(xml: &str) -> Result<LibraryMetadata, LbrError> {
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
let el = |n: roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;
let mut out = Vec::new();
for ds in doc.root_element().descendants().filter(|n| el(*n, "deviceset")) {
let ds_name = ds.attribute("name").unwrap_or("").to_string();
let ds_desc = ds.children().find(|c| el(*c, "description")).and_then(|d| d.text()).unwrap_or("").trim().to_string();
let mut variants = Vec::new();
for dev in ds.descendants().filter(|n| el(*n, "device")) {
let dev_name = dev.attribute("name").unwrap_or("").to_string();
// Fusion-authored libraries do not use EAGLE's <technology><attribute>. They layer
// <att_values> on the deviceset, again on the device, and then per named <att_set>
// (the "DEFAULT" column of Fusion's Variant Attributes dialog is the set named "").
// A later non-empty value wins. Both dialects are read, so a converted library that
// carries both still reports one truthful set per variant.
let layer = |parent: roxmltree::Node, into: &mut BTreeMap<String, String>| {
for holder in parent.children().filter(|c| el(*c, "att_values")) {
for a in holder.children().filter(|c| el(*c, "att_value")) {
let n = a.attribute("name").unwrap_or("").to_uppercase();
let v = a.attribute("value").unwrap_or("").trim().to_string();
if !n.is_empty() && !v.is_empty() { into.insert(n, v); }
}
}
};
let mut dev_base = BTreeMap::new();
layer(ds, &mut dev_base);
layer(dev, &mut dev_base);
let att_sets: Vec<roxmltree::Node> = dev.children().filter(|c| el(*c, "att_sets")).flat_map(|h| h.children().filter(|c| el(*c, "att_set")).collect::<Vec<_>>()).collect();
let set_values = |set: roxmltree::Node, into: &mut BTreeMap<String, String>| {
for a in set.children().filter(|c| el(*c, "att_value")) {
let n = a.attribute("name").unwrap_or("").to_uppercase();
let v = a.attribute("value").unwrap_or("").trim().to_string();
if !n.is_empty() && !v.is_empty() { into.insert(n, v); }
}
};
let tech_nodes: Vec<roxmltree::Node> = dev.descendants().filter(|n| el(*n, "technology")).collect();
// one variant per technology; with none, one per attribute set; with neither, one bare variant
let names: Vec<String> = if !tech_nodes.is_empty() { tech_nodes.iter().map(|t| t.attribute("name").unwrap_or("").to_string()).collect() }
else if !att_sets.is_empty() { att_sets.iter().map(|t| t.attribute("name").unwrap_or("").to_string()).collect() }
else { vec![String::new()] };
for tname in names {
let mut attrs = dev_base.clone();
if let Some(set) = att_sets.iter().find(|t| t.attribute("name").unwrap_or("") == tname) { set_values(*set, &mut attrs); }
if let Some(t) = tech_nodes.iter().find(|t| t.attribute("name").unwrap_or("") == tname) {
for a in t.descendants().filter(|n| el(*n, "attribute")) {
let n = a.attribute("name").unwrap_or("").to_uppercase();
let v = a.attribute("value").unwrap_or("").trim().to_string();
if !n.is_empty() && !v.is_empty() { attrs.insert(n, v); }
}
}
let mut missing: Vec<String> = ["MPN", "MANUFACTURER"].iter().filter(|k| attrs.get(**k).map(|v| v.is_empty()).unwrap_or(true)).map(|k| k.to_string()).collect();
if !DISTRIBUTOR_ATTRS.iter().any(|d| attrs.get(*d).map(|v| !v.is_empty()).unwrap_or(false)) {
missing.push("a distributor PN (MOUSER_PN/DIGIKEY_PN/LCSC_PN)".into());
}
variants.push(Variant { full_name: format!("{ds_name}{dev_name}{tname}"), device: dev_name.clone(), technology: tname,
package: dev.attribute("package").unwrap_or("").to_string(), attributes: attrs, missing });
}
}
out.push(Deviceset { name: ds_name, description: ds_desc, variants });
}
let totals = Totals {
devicesets: out.len(),
variants: out.iter().map(|d| d.variants.len()).sum(),
with_mpn: out.iter().flat_map(|d| &d.variants).filter(|v| v.attributes.get("MPN").map(|s| !s.is_empty()).unwrap_or(false)).count(),
with_distributor: out.iter().flat_map(|d| &d.variants).filter(|v| DISTRIBUTOR_ATTRS.iter().any(|d| v.attributes.get(*d).map(|s| !s.is_empty()).unwrap_or(false))).count(),
};
Ok(LibraryMetadata { ok: true, devicesets: out, totals })
}
/// alnum-only, case-folded: how two names are compared when their spellings differ.
pub fn norm(s: &str) -> String { s.to_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect() }
/// A library folder name Fusion and the wiki both accept.
pub fn library_name_ok(name: &str) -> bool {
let mut it = name.chars();
match it.next() { Some(c) if c.is_ascii_alphanumeric() => {}, _ => return false }
name.len() <= 64 && it.all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))
}
fn stem(p: &str) -> String {
let base = p.rsplit(['/', '\\']).next().unwrap_or(p);
match base.rfind('.') { Some(i) if i > 0 => base[..i].to_string(), _ => base.to_string() }
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PairedPart {
pub deviceset: String,
pub device: String,
pub package: String,
#[serde(rename = "lbrPath")] pub lbr_path: String,
#[serde(rename = "modelPath")] pub model_path: String,
#[serde(rename = "matchedBy")] pub matched_by: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Unbound { pub package: String, pub deviceset: String, pub device: String, pub why: String }
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Shared { pub deviceset: String, pub package: String, pub via: String }
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pairing {
pub parts: Vec<PairedPart>,
pub unbound: Vec<Unbound>,
pub shared: Vec<Shared>,
pub models: Vec<String>,
#[serde(rename = "modelsUnused")] pub models_unused: Vec<String>,
}
/// Pair every (deviceset, PACKAGE) in the .lbr with a STEP by name. ONE PART PER PACKAGE
/// VARIANT, not per deviceset (LAN7800 "", "-HV", "-SB" each bind their own STEP so Change
/// Package swaps footprint and 3D together); devicesets sharing an already-paired package are
/// `shared`, never unbound. Match order per unit: the package name, then deviceset+device (or
/// the bare deviceset), exact → normalised → normalised prefix (≥4 chars). A one-deviceset
/// library with one model pairs regardless of name. Same answers as `library_bundle.pair_parts`.
pub fn pair_parts(lbr_path: &str, models: &[String], metadata: &LibraryMetadata) -> Pairing {
let mut units: Vec<(String, String, String)> = Vec::new();
let mut devicesets = Vec::new();
for d in &metadata.devicesets {
devicesets.push(d.name.clone());
let mut seen: Vec<String> = Vec::new();
for v in &d.variants {
if v.package.is_empty() || seen.contains(&v.package) { continue; }
seen.push(v.package.clone());
units.push((d.name.clone(), v.device.clone(), v.package.clone()));
}
if seen.is_empty() { units.push((d.name.clone(), String::new(), String::new())); }
}
let primary_pkg = |ds: &str| units.iter().find(|(d, _, pk)| d == ds && !pk.is_empty()).map(|u| u.2.clone()).unwrap_or_default();
// later models overwrite earlier ones on a duplicate key, exactly like the dict comprehensions
let mut by_stem: BTreeMap<String, String> = BTreeMap::new();
let mut by_norm: Vec<(String, String)> = Vec::new(); // insertion-ordered like a dict
for m in models {
by_stem.insert(stem(m), m.clone());
let k = norm(&stem(m));
if let Some(e) = by_norm.iter_mut().find(|(kk, _)| *kk == k) { e.1 = m.clone(); } else { by_norm.push((k, m.clone())); }
}
let mut parts = Vec::new(); let mut unbound = Vec::new(); let mut shared = Vec::new();
let mut used: Vec<String> = Vec::new();
let mut bound_pkgs: BTreeMap<String, String> = BTreeMap::new();
for (ds, dev, pk) in &units {
if !pk.is_empty() && bound_pkgs.contains_key(pk) {
shared.push(Shared { deviceset: ds.clone(), package: pk.clone(), via: bound_pkgs[pk].clone() });
continue;
}
let mut cands: Vec<(String, &str)> = Vec::new();
if !pk.is_empty() { cands.push((pk.clone(), "package")); }
if !dev.is_empty() { cands.push((format!("{ds}{dev}"), "deviceset+device")); } else { cands.push((ds.clone(), "deviceset")); }
let mut hit: Option<(String, String)> = None;
for (name, how) in &cands {
if let Some(m) = by_stem.get(name) { if !used.contains(m) { hit = Some((m.clone(), how.to_string())); break; } }
}
if hit.is_none() {
for (name, how) in &cands {
let n = norm(name);
if let Some((_, m)) = by_norm.iter().find(|(k, _)| !n.is_empty() && *k == n) {
if !used.contains(m) { hit = Some((m.clone(), format!("{how}-normalised"))); break; }
}
}
}
if hit.is_none() {
'outer: for (name, how) in &cands {
let n = norm(name);
for (k, m) in &by_norm {
if !n.is_empty() && !used.contains(m) && (k.starts_with(&n) || n.starts_with(k.as_str())) && n.len() >= 4 {
hit = Some((m.clone(), format!("{how}-prefix"))); break 'outer;
}
}
}
}
match hit {
Some((m, how)) => {
used.push(m.clone());
let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };
parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: m, matched_by: how });
if !pk.is_empty() { bound_pkgs.insert(pk.clone(), ds.clone()); }
}
None => unbound.push(Unbound { package: if pk.is_empty() { ds.clone() } else { pk.clone() }, deviceset: ds.clone(), device: dev.clone(),
why: format!("no STEP in the bundle named like the package {} or the device {}", py_repr(pk), py_repr(&format!("{ds}{dev}"))) }),
}
}
if parts.is_empty() && units.len() == 1 && models.len() == 1 {
unbound.clear();
let (ds, dev, pk) = &units[0];
used.push(models[0].clone());
let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };
parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: models[0].clone(), matched_by: "only-one-of-each".into() });
}
let models_unused = models.iter().filter(|m| !used.contains(m)).cloned().collect();
Pairing { parts, unbound, shared, models: models.to_vec(), models_unused }
}
/// Python's `%r` for a str: single-quoted unless the text has a single quote and no double.
/// Python `repr(str)`: single quotes unless the text has a single quote and no double quote;
/// backslash, the quote in use, and control characters escaped the way CPython escapes them.
fn py_repr(s: &str) -> String {
let dq = s.contains('\'') && !s.contains('"');
let q = if dq { '"' } else { '\'' };
let mut out = String::with_capacity(s.len() + 2);
out.push(q);
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"),
c if c == q => { out.push('\\'); out.push(c); }
c if (c as u32) < 0x20 || c as u32 == 0x7f => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out.push(q);
out
}
// ───────────────────────── per-part split (library_bundle.split_per_part) ─────────────────────────
/// An owned, editable element tree that serialises exactly like Python's ElementTree
/// (`ET.ElementTree(root).write(fp, encoding="utf-8", xml_declaration=True)`): attributes in
/// document order, `<x />` for an element with no text and no children, `&`/`<`/`>` escaped
/// in text, plus `"` and the three control characters in attributes, no DOCTYPE, no root
/// tail. Comments and processing instructions are dropped, as ET's default parser drops them.
#[derive(Debug, Clone, PartialEq)]
pub struct Element {
pub tag: String,
pub attrs: Vec<(String, String)>,
/// Text before the first child (ET `.text`).
pub text: String,
pub children: Vec<Element>,
/// Text after this element's close, inside the parent (ET `.tail`).
pub tail: String,
}
impl Element {
pub fn new(tag: &str) -> Element { Element { tag: tag.into(), attrs: vec![], text: String::new(), children: vec![], tail: String::new() } }
pub fn attr(&self, k: &str) -> Option<&str> { self.attrs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.as_str()) }
pub fn find_child(&self, tag: &str) -> Option<&Element> { self.children.iter().find(|c| c.tag == tag) }
pub fn find_child_mut(&mut self, tag: &str) -> Option<&mut Element> { self.children.iter_mut().find(|c| c.tag == tag) }
/// Depth-first search (ET `.//tag`).
pub fn find_deep_mut(&mut self, tag: &str) -> Option<&mut Element> {
if self.tag == tag { return Some(self); }
for c in self.children.iter_mut() { if let Some(f) = c.find_deep_mut(tag) { return Some(f); } }
None
}
fn from_node(n: roxmltree::Node) -> Element {
let mut e = Element::new(n.tag_name().name());
e.attrs = n.attributes().map(|a| (a.name().to_string(), a.value().to_string())).collect();
let mut last_child: Option<usize> = None;
for c in n.children() {
if c.is_element() {
e.children.push(Element::from_node(c));
last_child = Some(e.children.len() - 1);
} else if c.is_text() {
match last_child { Some(i) => e.children[i].tail.push_str(c.text().unwrap_or("")), None => e.text.push_str(c.text().unwrap_or("")) }
}
}
e
}
pub fn parse(xml: &str) -> Result<Element, LbrError> {
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
Ok(Element::from_node(doc.root_element()))
}
fn write(&self, out: &mut String, is_root: bool) {
out.push('<'); out.push_str(&self.tag);
for (k, v) in &self.attrs { out.push(' '); out.push_str(k); out.push_str("=\""); out.push_str(&escape_attrib(v)); out.push('"'); }
if self.text.is_empty() && self.children.is_empty() { out.push_str(" />"); }
else {
out.push('>'); out.push_str(&escape_cdata(&self.text));
for c in &self.children { c.write(out, false); }
out.push_str("</"); out.push_str(&self.tag); out.push('>');
}
if !is_root { out.push_str(&escape_cdata(&self.tail)); }
}
/// The whole document as Python's ElementTree writes it.
pub fn to_xml_document(&self) -> String {
let mut s = String::from("<?xml version='1.0' encoding='utf-8'?>\n");
self.write(&mut s, true);
s
}
}
fn escape_cdata(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") }
fn escape_attrib(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
.replace('\r', " ").replace('\n', " ").replace('\t', "	")
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SplitEntry {
#[serde(rename = "lbrPath")] pub lbr_path: String,
pub package: String,
pub symbols: Vec<String>,
pub packages: Vec<String>,
}
fn safe_name(s: &str) -> String {
let mut out = String::new(); let mut in_run = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() || "._-".contains(c) { out.push(c); in_run = false; }
else if !in_run { out.push('_'); in_run = true; }
}
out
}
/// One library per deviceset AND one per (deviceset, package variant), so Fusion's Package3D
/// generator (which loads the FIRST package of the open library whatever path it is given)
/// mints each chip on its own footprint. Each file keeps the merged library's header and
/// layers and carries exactly the packages that deviceset's devices use (primary first), the
/// symbols its gates use, and the deviceset itself, with any prior `<package3dinstances>`
/// stripped. Keys: `<deviceset>` (primary package file) and `<deviceset>::<package>`.
/// Byte-for-byte what `library_bundle.split_per_part` writes (golden-tested).
pub fn split_per_part(xml: &str, out_dir: &std::path::Path) -> Result<BTreeMap<String, SplitEntry>, LbrError> {
let root = Element::parse(xml)?;
std::fs::create_dir_all(out_dir).map_err(|e| LbrError::NotALibrary(format!("cannot create {}: {e}", out_dir.display())))?;
let mut probe = root.clone();
let lib = probe.find_deep_mut("library").ok_or_else(|| LbrError::NotALibrary("no <library>".into()))?.clone();
let pk_all: Vec<Element> = lib.find_child("packages").map(|p| p.children.iter().filter(|c| c.tag == "package").cloned().collect()).unwrap_or_default();
let sy_all: Vec<Element> = lib.find_child("symbols").map(|p| p.children.iter().filter(|c| c.tag == "symbol").cloned().collect()).unwrap_or_default();
let devicesets: Vec<Element> = lib.find_child("devicesets").map(|p| p.children.iter().filter(|c| c.tag == "deviceset").cloned().collect()).unwrap_or_default();
let mut out = BTreeMap::new();
for ds in &devicesets {
let name = ds.attr("name").unwrap_or("").to_string();
let mut pkgs: Vec<String> = Vec::new();
if let Some(devs) = ds.find_child("devices") {
for d in devs.children.iter().filter(|c| c.tag == "device") {
if let Some(pk) = d.attr("package") { if !pk.is_empty() && !pkgs.iter().any(|p| p == pk) { pkgs.push(pk.to_string()); } }
}
}
let mut syms: Vec<String> = Vec::new();
if let Some(gates) = ds.find_child("gates") {
for g in gates.children.iter().filter(|c| c.tag == "gate") {
if let Some(sy) = g.attr("symbol") { if !sy.is_empty() && !syms.iter().any(|s| s == sy) { syms.push(sy.to_string()); } }
}
}
let mut variants: Vec<Option<String>> = vec![None];
variants.extend(pkgs.iter().cloned().map(Some));
for only_pk in variants {
let mut new_root = root.clone();
let new_lib = new_root.find_deep_mut("library").unwrap();
// ET.remove drops the element AND its tail
new_lib.children.retain(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets" | "packages3d"));
let use_pkgs: Vec<String> = match &only_pk { Some(p) => vec![p.clone()], None => pkgs.clone() };
let mut pe = Element::new("packages");
for pk in &use_pkgs { if let Some(p) = pk_all.iter().find(|p| p.attr("name") == Some(pk.as_str())) { pe.children.push(p.clone()); } }
let mut se = Element::new("symbols");
for sy in &syms { if let Some(s) = sy_all.iter().find(|s| s.attr("name") == Some(sy.as_str())) { se.children.push(s.clone()); } }
let mut de = Element::new("devicesets");
let mut ds2 = ds.clone();
if let Some(devs) = ds2.find_child_mut("devices") {
for dev in devs.children.iter_mut().filter(|c| c.tag == "device") {
dev.children.retain(|c| c.tag != "package3dinstances");
}
if let Some(pk) = &only_pk {
devs.children.retain(|c| c.tag != "device" || c.attr("package") == Some(pk.as_str()));
}
}
de.children.push(ds2);
// EAGLE order inside <library>: description?, packages, symbols, devicesets
let head: Vec<Element> = new_lib.children.iter().filter(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets")).cloned().collect();
new_lib.children = head; new_lib.children.push(pe); new_lib.children.push(se); new_lib.children.push(de);
let mut safe = safe_name(&name); if safe.is_empty() { safe = "part".into(); }
if let Some(pk) = &only_pk { let mut sp = safe_name(pk); if sp.is_empty() { sp = "pkg".into(); } safe = format!("{safe}__{sp}"); }
let fp = out_dir.join(format!("{safe}.lbr"));
std::fs::write(&fp, new_root.to_xml_document()).map_err(|e| LbrError::NotALibrary(format!("cannot write {}: {e}", fp.display())))?;
let entry = SplitEntry { lbr_path: fp.to_string_lossy().replace('\\', "/"), package: only_pk.clone().unwrap_or_else(|| pkgs.first().cloned().unwrap_or_default()), symbols: syms.clone(), packages: use_pkgs };
match &only_pk { Some(pk) => { out.insert(format!("{name}::{pk}"), entry); } None => { out.insert(name.clone(), entry); } }
}
}
Ok(out)
}
// ───────────────────────── bundle staging (library_bundle.py) ─────────────────────────
pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;
const MODEL_EXT: [&str; 2] = [".step", ".stp"];
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("unsafe zip entry: {0}")]
UnsafeEntry(String),
#[error("bundle unpacks past the {0} MB cap")]
TooBig(u64),
#[error("zip error: {0}")]
Zip(String),
#[error("io error: {0}")]
Io(String),
}
/// Extract with a zip-slip guard and an unpacked-size cap. Returns files extracted.
/// Same refusals as `library_bundle.safe_extract`: an absolute entry, a drive-letter entry,
/// or any `..` segment is refused before a byte is written.
pub fn safe_extract(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<usize, BundleError> {
let f = std::fs::File::open(zip_path).map_err(|e| BundleError::Io(e.to_string()))?;
let mut zf = zip::ZipArchive::new(f).map_err(|e| BundleError::Zip(e.to_string()))?;
std::fs::create_dir_all(dest).map_err(|e| BundleError::Io(e.to_string()))?;
let dest = dest.canonicalize().map_err(|e| BundleError::Io(e.to_string()))?;
let (mut total, mut count) = (0u64, 0usize);
for i in 0..zf.len() {
let mut entry = zf.by_index(i).map_err(|e| BundleError::Zip(e.to_string()))?;
let name = entry.name().to_string();
if name.is_empty() || name.ends_with('/') { continue; }
let norm = name.replace('\\', "/");
let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';
if norm.starts_with('/') || drive || norm.split('/').any(|s| s == "..") { return Err(BundleError::UnsafeEntry(name)); }
total += entry.size();
if total > MAX_UNPACKED_BYTES { return Err(BundleError::TooBig(MAX_UNPACKED_BYTES >> 20)); }
let target = dest.join(&norm);
if !target.starts_with(&dest) { return Err(BundleError::UnsafeEntry(name)); }
if let Some(p) = target.parent() { std::fs::create_dir_all(p).map_err(|e| BundleError::Io(e.to_string()))?; }
let mut out = std::fs::File::create(&target).map_err(|e| BundleError::Io(e.to_string()))?;
std::io::copy(&mut entry, &mut out).map_err(|e| BundleError::Io(e.to_string()))?;
count += 1;
}
Ok(count)
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BundleFiles {
/// The shallowest .lbr (ties broken by path), or None.
pub lbr: Option<String>,
/// Every .step/.stp anywhere in the bundle, sorted.
pub models: Vec<String>,
pub all_lbrs: Vec<String>,
}
fn walk(dir: &std::path::Path, out: &mut Vec<String>) {
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() { walk(&p, out); } else { out.push(p.to_string_lossy().to_string()); }
}
}
}
/// The shallowest .lbr wins; models are every .step/.stp anywhere in the bundle.
pub fn find_bundle_files(root: &std::path::Path) -> BundleFiles {
let mut files = Vec::new(); walk(root, &mut files);
let sep = std::path::MAIN_SEPARATOR;
let mut lbrs: Vec<String> = files.iter().filter(|f| f.to_lowercase().ends_with(".lbr")).cloned().collect();
let mut models: Vec<String> = files.iter().filter(|f| { let l = f.to_lowercase(); MODEL_EXT.iter().any(|x| l.ends_with(x)) }).cloned().collect();
lbrs.sort_by(|a, b| (a.matches(sep).count(), a.as_str()).cmp(&(b.matches(sep).count(), b.as_str())));
models.sort();
BundleFiles { lbr: lbrs.first().cloned(), models, all_lbrs: lbrs }
}
/// Copy the .lbr and its models to the stable install folder; returns the new .lbr path and
/// the old→new model map, forward slashes throughout (what the chain and the wiki expect).
pub fn place_library(lbr_path: &std::path::Path, models: &[String], dest_dir: &std::path::Path) -> Result<(String, BTreeMap<String, String>), BundleError> {
std::fs::create_dir_all(dest_dir).map_err(|e| BundleError::Io(e.to_string()))?;
let new_lbr = dest_dir.join(lbr_path.file_name().unwrap_or_default());
std::fs::copy(lbr_path, &new_lbr).map_err(|e| BundleError::Io(e.to_string()))?;
let mut map = BTreeMap::new();
for m in models {
let src = std::path::Path::new(m);
let t = dest_dir.join(src.file_name().unwrap_or_default());
let same = match (src.canonicalize(), t.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => false };
if !same { std::fs::copy(src, &t).map_err(|e| BundleError::Io(e.to_string()))?; }
map.insert(m.clone(), t.to_string_lossy().replace('\\', "/"));
}
Ok((new_lbr.to_string_lossy().replace('\\', "/"), map))
}
// ───────────────────────── preflight + lints (server.py) ─────────────────────────
/// Python's `repr(float)` for the values these lints print (shortest round-trip, with a
/// trailing `.0` on integral values), so warning texts match the Python bridge exactly.
pub fn py_float(v: f64) -> String {
if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e16 { format!("{:.1}", v) } else { format!("{}", v) }
}
/// Python's `repr(str)`: single quotes unless the text has a single quote and no double.
pub fn py_repr_str(s: &str) -> String { py_repr(s) }
fn round3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Check { pub check: String, pub ok: bool, pub detail: String }
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ConnectCheck {
pub ok: bool,
#[serde(default)] pub connects: usize,
#[serde(default, rename = "badPinRefs")] pub bad_pin_refs: Vec<(Option<String>, Option<String>)>,
#[serde(default, rename = "badPadRefs")] pub bad_pad_refs: Vec<(Option<String>, Option<String>)>,
#[serde(default, rename = "unconnectedPads")] pub unconnected_pads: Vec<String>,
pub why: Option<String>,
}
/// Referential integrity of the pin→pad mapping (the fifth artifact). A connect's `pad` is a
/// SPACE-SEPARATED list (QFN thermal vias, BGA balls), so every name in it is checked.
pub fn check_connects(xml: &str) -> ConnectCheck {
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let Ok(doc) = roxmltree::Document::parse_with_options(xml, opts) else {
return ConnectCheck { ok: false, why: Some("library unreadable".into()), ..Default::default() };
};
let el = |n: roxmltree::Node, t: &str| n.is_element() && n.tag_name().name() == t;
let root = doc.root_element();
let named = |t: &str| -> std::collections::BTreeSet<String> { root.descendants().filter(|n| el(*n, t)).filter_map(|n| n.attribute("name").map(str::to_string)).collect() };
let pins = named("pin");
let mut pads = named("smd"); pads.extend(named("pad"));
let connects: Vec<(Option<String>, Option<String>)> = root.descendants().filter(|n| el(*n, "connect"))
.map(|c| (c.attribute("pin").map(str::to_string), c.attribute("pad").map(str::to_string))).collect();
let bad_pin: Vec<_> = connects.iter().filter(|c| !c.0.as_ref().map(|p| pins.contains(p)).unwrap_or(false)).cloned().collect();
let bad_pad: Vec<_> = connects.iter().filter(|c| {
let names: Vec<&str> = c.1.as_deref().unwrap_or("").split_whitespace().collect();
names.is_empty() || names.iter().any(|p| !pads.contains(*p))
}).cloned().collect();
let connected: std::collections::BTreeSet<String> = connects.iter().flat_map(|c| c.1.as_deref().unwrap_or("").split_whitespace().map(str::to_string).collect::<Vec<_>>()).collect();
let unconnected: Vec<String> = pads.iter().filter(|p| !connected.contains(*p)).cloned().collect();
let ok = !connects.is_empty() && bad_pin.is_empty() && bad_pad.is_empty();
let why = if ok { None } else if connects.is_empty() { Some("no connects at all".into()) }
else { Some(format!("connects reference missing {}", if !bad_pin.is_empty() { "pins" } else { "pads" })) };
ConnectCheck { ok, connects: connects.len(), bad_pin_refs: bad_pin.into_iter().take(8).collect(),
bad_pad_refs: bad_pad.into_iter().take(8).collect(), unconnected_pads: unconnected.into_iter().take(12).collect(), why }
}
/// One EE-grade warning about a library (metadata or geometry); the same keys Python emits.
pub type Warning = serde_json::Map<String, serde_json::Value>;
fn warn(pairs: &[(&str, serde_json::Value)]) -> Warning {
let mut m = Warning::new();
for (k, v) in pairs { m.insert(k.to_string(), v.clone()); }
m
}
/// Shape-aware geometry checks: stacked pins, pad copper overlap or < 0.2 mm clearance,
/// generic class names / descriptions. Same warnings as `_lint_library_geometry`.
pub fn lint_library_geometry(xml: &str) -> Vec<Warning> {
use serde_json::json;
let mut out = Vec::new();
let Ok(root) = Element::parse(xml) else { return out };
let mut probe = root.clone();
let Some(lib) = probe.find_deep_mut("library") else { return out };
let lib = lib.clone();
let f = |e: &Element, k: &str| -> f64 { e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) };
// 1. symbol pins at identical coordinates
if let Some(symbols) = lib.find_child("symbols") {
for sym in symbols.children.iter().filter(|c| c.tag == "symbol") {
let mut seen: Vec<((f64, f64), String)> = Vec::new();
for pin in sym.children.iter().filter(|c| c.tag == "pin") {
let key = (round3(f(pin, "x")), round3(f(pin, "y")));
let name = pin.attr("name").unwrap_or("").to_string();
if let Some((_, prev)) = seen.iter().find(|(k, _)| *k == key) {
out.push(warn(&[("code", json!("symbol_pins_stacked")), ("symbol", json!(sym.attr("name"))),
("detail", json!(format!("pins {} and {} sit at the same point ({}, {}); one hides the other", py_repr(prev), py_repr(&name), py_float(key.0), py_float(key.1))))]));
}
seen.retain(|(k, _)| *k != key); seen.push((key, name));
}
let has = |t: &str| sym.children.iter().any(|c| c.tag == t);
let pins = sym.children.iter().filter(|c| c.tag == "pin").count();
if !has("wire") && !has("rectangle") && !has("circle") && !has("polygon") && pins <= 4 {
out.push(warn(&[("code", json!("symbol_no_body")), ("symbol", json!(sym.attr("name"))),
("detail", json!("symbol has pins but no body graphics (the converter dropped them)"))]));
}
}
}
// 2. pad copper overlap / clearance
let bbox = |e: &Element| -> (f64, f64, f64, f64) {
let (x, y) = (f(e, "x"), f(e, "y"));
let (mut dx, mut dy) = if e.tag == "smd" { (f(e, "dx"), f(e, "dy")) } else {
let mut dia = f(e, "diameter"); if dia == 0.0 { dia = f(e, "drill") * 1.5; }
let shape = e.attr("shape").unwrap_or("round").to_lowercase();
if shape == "long" { (dia * 2.0, dia) } else { (dia, dia) }
};
let rot = e.attr("rot").unwrap_or("");
if rot.contains("R90") || rot.contains("R270") { std::mem::swap(&mut dx, &mut dy); }
(x - dx / 2.0, y - dy / 2.0, x + dx / 2.0, y + dy / 2.0)
};
if let Some(packages) = lib.find_child("packages") {
for pkg in packages.children.iter().filter(|c| c.tag == "package") {
let pads: Vec<&Element> = pkg.children.iter().filter(|c| c.tag == "smd").chain(pkg.children.iter().filter(|c| c.tag == "pad")).collect();
let boxes: Vec<(String, (f64, f64, f64, f64))> = pads.iter().map(|p| (p.attr("name").unwrap_or("").to_string(), bbox(p))).collect();
// pads that share a base name are one net by construction (a heatsink via under an
// exposed pad is written as B4@2, B4@3 ... and connected to B4's pin): copper that
// meets between them is the thermal connection, never a short (page issue #89)
let base = |n: &str| n.split('@').next().unwrap_or(n).trim().to_ascii_uppercase();
let mut thermal: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for i in 0..boxes.len() {
for j in i + 1..boxes.len() {
let (na, a) = &boxes[i]; let (nb, b) = &boxes[j];
let gap_x = (a.0 - b.2).max(b.0 - a.2);
let gap_y = (a.1 - b.3).max(b.1 - a.3);
let gap = gap_x.max(gap_y);
if base(na) == base(nb) && !na.is_empty() {
if gap < 0.2 { *thermal.entry(base(na)).or_insert(0) += 1; }
continue;
}
if gap < 0.0 {
out.push(warn(&[("code", json!("pad_overlap")), ("package", json!(pkg.attr("name"))),
("detail", json!(format!("pads {} and {} overlap (copper intersects): a short on the board", py_repr(na), py_repr(nb))))]));
} else if gap < 0.2 {
out.push(warn(&[("code", json!("pad_clearance")), ("package", json!(pkg.attr("name"))),
("detail", json!(format!("pads {} and {} are only {:.2} mm apart", py_repr(na), py_repr(nb), gap)))]));
}
}
}
for (b, n) in thermal {
out.push(warn(&[("code", json!("heatsink_vias")), ("level", json!("info")), ("package", json!(pkg.attr("name"))),
("detail", json!(format!("{n} same-net pad join(s) under {}: heatsink vias (B4@n) on the exposed pad's own net, the intended thermal connection", py_repr(&b))))]));
}
}
}
// 3. generic names / descriptions
let gen_name = regex::Regex::new(r"(?i)^(L|R|C|D|LED|Q|Y|X|F|FB|J|Conn_\d+x\d+.*|Crystal(_\w+)?|Resistor|Capacitor|Inductor)$").unwrap();
let gen_desc = regex::Regex::new(r"(?i)^(inductor|resistor|capacitor|unpolarized capacitor|polarized capacitor|light emitting diode|led|diode|generic connector.*|four pin crystal.*|crystal)$").unwrap();
if let Some(devicesets) = lib.find_child("devicesets") {
for ds in devicesets.children.iter().filter(|c| c.tag == "deviceset") {
let n = ds.attr("name").unwrap_or("").to_string();
let d = ds.find_child("description").map(|x| x.text.trim().to_string()).unwrap_or_default();
if gen_name.is_match(&n) {
out.push(warn(&[("code", json!("generic_deviceset_name")), ("deviceset", json!(n)),
("detail", json!(format!("deviceset is named {}, a class name, not a part; the MPN belongs here", py_repr(&n))))]));
}
if !d.is_empty() && gen_desc.is_match(&d) {
out.push(warn(&[("code", json!("generic_description")), ("deviceset", json!(n)),
("detail", json!(format!("description {} says the class, not the part (value, package, maker)", py_repr(&d))))]));
}
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MetadataLint {
pub ok: bool,
#[serde(default)] pub warnings: Vec<Warning>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub totals: Option<Totals>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option<String>,
}
/// EE-grade warnings about part metadata (never blocks opening), then geometry. `ok` means
/// "fit to ORDER from", a different question from "will open". Same as `_lint_part_metadata`.
pub fn lint_part_metadata(xml: &str) -> MetadataLint {
use serde_json::json;
let md = match library_part_metadata(xml) { Ok(m) => m, Err(e) => return MetadataLint { ok: false, warnings: vec![], totals: None, error: Some(e.to_string()) } };
let mut warns = Vec::new();
for ds in &md.devicesets {
if ds.description.is_empty() {
warns.push(warn(&[("code", json!("deviceset_no_description")), ("deviceset", json!(ds.name)),
("detail", json!("No <description>. Fusion's Content Manager shows this to whoever places the part; without it they are picking on a symbol outline alone."))]));
}
for v in &ds.variants {
if !v.missing.is_empty() {
warns.push(warn(&[("code", json!("variant_missing_part_numbers")), ("deviceset", json!(ds.name)), ("variant", json!(v.full_name)),
("missing", json!(v.missing)),
("detail", json!(format!("Missing {}. This bridge's own BOM export reads these exact attribute names, so the BOM comes out blank for this part.", v.missing.join(", "))))]));
}
}
if ds.variants.len() > 1 {
let mpns: Vec<Option<&String>> = ds.variants.iter().map(|v| v.attributes.get("MPN").filter(|s| !s.is_empty())).collect();
let named: Vec<&String> = mpns.iter().flatten().cloned().collect();
if named.len() < ds.variants.len() {
warns.push(warn(&[("code", json!("variants_indistinguishable")), ("deviceset", json!(ds.name)),
("detail", json!(format!("{} variants but only {} carry an MPN. Variants that share a symbol, footprint and 3D body are distinguishable ONLY by metadata - an automotive grade part and a baseline one look identical here.", ds.variants.len(), named.len())))]));
} else {
let mut uniq = named.clone(); uniq.sort(); uniq.dedup();
if uniq.len() < named.len() {
let mut dups: Vec<&String> = named.iter().filter(|m| named.iter().filter(|n| n == m).count() > 1).cloned().collect();
dups.sort(); dups.dedup();
let list: Vec<String> = dups.into_iter().take(3).cloned().collect();
warns.push(warn(&[("code", json!("duplicate_variant_mpn")), ("deviceset", json!(ds.name)),
("detail", json!(format!("Two or more variants share the same MPN ({}), so they are not actually different parts - or one MPN is wrong.", list.join(", "))))]));
}
}
}
}
warns.extend(lint_library_geometry(xml));
MetadataLint { ok: warns.is_empty(), warnings: warns, totals: Some(md.totals), error: None }
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Preflight {
pub ok: bool,
pub checks: Vec<Check>,
pub errors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "metadataOk")] pub metadata_ok: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "metadataWarnings")] pub metadata_warnings: Option<Vec<Warning>>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "partTotals")] pub part_totals: Option<Totals>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "_metadataHint")] pub metadata_hint: Option<String>,
}
const EAGLE_ALIGNS: [&str; 9] = ["bottom-left", "bottom-center", "bottom-right", "center-left", "center", "center-right", "top-left", "top-center", "top-right"];
/// Will Fusion open this .lbr? Every rejection Fusion has actually produced, as a named
/// offline check: truncated file, empty packages, dead connects, absent devicesets, duplicate
/// pad names. `ok` keeps meaning "will open"; ordering fitness rides separately.
pub fn preflight_lbr(path: &std::path::Path) -> Preflight {
let mut checks: Vec<Check> = Vec::new();
let mut c = |name: &str, ok: bool, detail: String| -> bool { checks.push(Check { check: name.into(), ok, detail }); ok };
let sz = match std::fs::metadata(path) {
Ok(m) if m.is_file() => m.len(),
Ok(_) => { c("file-readable", false, format!("[Errno 21] Is a directory: {}", py_repr(&path.to_string_lossy()))); return finish(checks, None); }
Err(e) => { c("file-readable", false, format!("[Errno {}] {}: {}", e.raw_os_error().unwrap_or(2), if e.kind() == std::io::ErrorKind::NotFound { "No such file or directory" } else { "cannot read" }, py_repr(&path.to_string_lossy()))); return finish(checks, None); }
};
c("file-readable", true, format!("{sz} bytes"));
c("size-sane", sz >= 200, format!("{sz} bytes - a real EAGLE library is never this small (truncated transfer?)"));
let mut xml: Option<String> = None;
if sz >= 200 {
let text = std::fs::read_to_string(path).unwrap_or_default();
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
match roxmltree::Document::parse_with_options(&text, opts) {
Ok(_) => { c("xml-parses", true, String::new()); xml = Some(text); }
Err(e) => { c("xml-parses", false, e.to_string().chars().take(160).collect()); }
}
}
if let Some(text) = &xml {
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(text, opts).unwrap();
let root = doc.root_element();
let el = |n: roxmltree::Node, t: &str| n.is_element() && n.tag_name().name() == t;
c("root-is-eagle", root.tag_name().name() == "eagle", format!("root is <{}>", root.tag_name().name()));
let count = |t: &str| root.descendants().filter(|n| el(*n, t)).count();
let (pkgs, syms, devs) = (count("package"), count("symbol"), count("deviceset"));
c("packages-nonempty", pkgs > 0, if pkgs > 0 { format!("{pkgs} package(s)") } else { "EMPTY <packages> - Fusion opens this to a silent 'No Components' library".into() });
c("symbols-present", syms > 0, format!("{syms} symbol(s)"));
let bad_align: Vec<String> = root.descendants().filter(|n| el(*n, "text")).filter_map(|t| t.attribute("align")).filter(|a| !EAGLE_ALIGNS.contains(a)).map(str::to_string).collect();
let mut uniq = bad_align.clone(); uniq.sort(); uniq.dedup();
c("text-align-valid", true, if bad_align.is_empty() { "all <text> align values are EAGLE values".into() } else {
format!("{} <text> element(s) carry a non-EAGLE align value ([{}]); Fusion will list a warning for each on open. Fix upstream (adom-lbr group captions).", bad_align.len(), uniq.iter().map(|s| py_repr(s)).collect::<Vec<_>>().join(", ")) });
c("devicesets-present", devs > 0, if devs > 0 { format!("{devs} deviceset(s)") } else { "no <deviceset> - EDIT <name>.dev will raise Fusion's Create-new prompt".into() });
if pkgs > 0 && syms > 0 {
let conn = check_connects(text);
c("connects-resolve", conn.ok, if conn.ok { format!("{} connect(s), all resolving", conn.connects) } else { conn.why.clone().unwrap_or_else(|| "invalid".into()).chars().take(160).collect() });
}
let mut dups: Vec<String> = Vec::new();
for pkg in root.descendants().filter(|n| el(*n, "package")) {
let mut seen: Vec<(String, usize)> = Vec::new();
let names = pkg.descendants().filter(|n| el(*n, "smd")).chain(pkg.descendants().filter(|n| el(*n, "pad"))).filter_map(|p| p.attribute("name").map(str::to_string));
for nm in names { match seen.iter_mut().find(|(k, _)| *k == nm) { Some(e) => e.1 += 1, None => seen.push((nm, 1)) } }
dups.extend(seen.into_iter().filter(|(_, n)| *n > 1).map(|(n, cnt)| format!("{}/{} x{}", pkg.attribute("name").unwrap_or(""), n, cnt)));
}
c("pads-unique", dups.is_empty(), if dups.is_empty() { "all pad names unique per package".into() } else {
format!("duplicate pad names - Fusion's importer refuses the ENTIRE library over these: {}. Rename to e.g. 17, 17@2, 17@3...", dups.iter().take(6).cloned().collect::<Vec<_>>().join(", ")) });
// A 3D package naming a package the library does not have makes Fusion refuse the ENTIRE
// library: an Error modal and an empty design. Only libraries with bindings get this check.
if root.descendants().any(|n| el(n, "packageinstance")) {
let names: Vec<&str> = root.descendants().filter(|n| el(*n, "package")).filter_map(|p| p.attribute("name")).collect();
let dangling: Vec<&str> = root.descendants().filter(|n| el(*n, "packageinstance")).filter_map(|p| p.attribute("name")).filter(|n| !names.iter().any(|k| k.eq_ignore_ascii_case(n))).collect();
c("package3d-refs-resolve", dangling.is_empty(), if dangling.is_empty() { "every 3D binding names a package in this library".into() } else {
format!("3D binding(s) name packages this library does not have, so Fusion refuses the ENTIRE library: {}. Bind to the <package> name, not the deviceset's.", dangling.iter().take(6).copied().collect::<Vec<_>>().join(", ")) });
}
}
finish(checks, xml.as_deref())
}
fn finish(checks: Vec<Check>, xml: Option<&str>) -> Preflight {
let ok = checks.iter().all(|c| c.ok);
let errors = checks.iter().filter(|c| !c.ok).map(|c| format!("{}: {}", c.check, c.detail)).collect();
let mut res = Preflight { ok, checks, errors, ..Default::default() };
if let Some(text) = xml {
let lint = lint_part_metadata(text);
if lint.error.is_none() {
res.metadata_ok = Some(lint.ok);
if !lint.ok {
res.metadata_hint = Some(format!("The library will open, but {} part-metadata warning(s) mean an EE cannot order from it: MPN / MANUFACTURER / a distributor PN (MOUSER_PN, DIGIKEY_PN, LCSC_PN) are what this bridge's own BOM export reads, and variants that share geometry are told apart ONLY by these. See fusion_library_parts for the per-variant breakdown.", lint.warnings.len()));
}
res.metadata_warnings = Some(lint.warnings);
res.part_totals = lint.totals;
}
}
res
}
#[cfg(test)]
mod heatsink_tests {
#[test]
fn preflight_refuses_a_3d_binding_to_a_missing_package() {
let lib = |instance: &str| format!(r#"<?xml version="1.0"?><eagle><drawing><library><packages><package name="SOIC-8"><smd name="1" x="0" y="0" dx="1" dy="1" layer="1"/></package></packages><packages3d><package3d name="{instance}" wip_urn="urn:x"><packageinstances><packageinstance name="{instance}"/></packageinstances></package3d></packages3d><symbols><symbol name="S"/></symbols><devicesets><deviceset name="NY8A054ES8"><devices><device name="" package="SOIC-8"><connects/></device></devices></deviceset></devicesets></library></drawing></eagle>"#);
let dir = std::env::temp_dir().join(format!("fusion-lbr-refs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let check = |instance: &str| {
let path = dir.join(format!("{instance}.lbr"));
std::fs::write(&path, lib(instance)).unwrap();
crate::preflight_lbr(&path).checks.into_iter().find(|c| c.check == "package3d-refs-resolve").expect("the check runs on a library with bindings")
};
let bad = check("NY8A054ES8");
assert!(!bad.ok, "a binding to the deviceset's name is refused");
assert!(bad.detail.contains("NY8A054ES8"), "and names it: {}", bad.detail);
assert!(check("SOIC-8").ok, "a binding to the real package passes");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn heatsink_vias_are_not_shorts() {
let lbr = r#"<eagle version="9.6.2"><drawing><library><packages><package name="QFN-EP">
<smd name="B4" x="0" y="0" dx="3" dy="3" layer="1"/>
<pad name="B4@2" x="0.7" y="0.7" drill="0.3" diameter="0.6"/>
<pad name="B4@3" x="-0.7" y="0.7" drill="0.3" diameter="0.6"/>
<smd name="1" x="0" y="0.2" dx="1" dy="1" layer="1"/>
</package></packages></library></drawing></eagle>"#;
let w = super::lint_library_geometry(lbr);
let codes: Vec<String> = w.iter().map(|x| x["code"].as_str().unwrap_or("").to_string()).collect();
let overlaps: Vec<&serde_json::Map<String, serde_json::Value>> = w.iter().filter(|x| x["code"] == "pad_overlap").collect();
assert!(overlaps.iter().all(|o| o["detail"].as_str().unwrap_or("").contains("'1'")), "only the genuinely different pad '1' may be flagged: {codes:?}");
assert!(w.iter().any(|x| x["code"] == "heatsink_vias" && x["level"] == "info"), "same-net joins are reported as info: {codes:?}");
}
}
#[cfg(test)]
mod fusion_att_tests {
use super::*;
#[test]
fn fusion_att_values_are_read_in_layers() {
let xml = r#"<?xml version="1.0"?><eagle><drawing><library><devicesets>
<deviceset name="VL53L5CX" prefix="U"><description>ToF</description>
<att_values><att_value name="MANUFACTURER" value="STMicroelectronics"/><att_value name="DATASHEET" value="https://x/ds.pdf"/></att_values>
<devices><device name="" package="VL53L5CX">
<att_values><att_value name="MPN" value=""/><att_value name="MOUSER_PN" value=""/></att_values>
<att_sets><att_set name=""><att_value name="MPN" value="VL53L5CXV0GC/1"/><att_value name="MOUSER_PN" value="511-VL53L5CXV0GC/1"/></att_set></att_sets>
</device></devices></deviceset></devicesets></library></drawing></eagle>"#;
let md = library_part_metadata(xml).unwrap();
let v = &md.devicesets[0].variants[0];
assert_eq!(v.attributes.get("MPN").map(String::as_str), Some("VL53L5CXV0GC/1"));
assert_eq!(v.attributes.get("MANUFACTURER").map(String::as_str), Some("STMicroelectronics"));
assert_eq!(v.attributes.get("MOUSER_PN").map(String::as_str), Some("511-VL53L5CXV0GC/1"));
assert!(v.missing.is_empty(), "{:?}", v.missing);
assert_eq!(md.totals.with_mpn, 1);
}
}
/// The standard's read_footprint: every detail of one package, from a .lbr or from a library a
/// board embeds (`library` names it; a .lbr has one). Pads with their number, type, shape,
/// position, size, drill and layers; the layers used; the courtyard box (EAGLE's component
/// exclude layers 39/40, else the pads with the placement outline); graphics per layer; the
/// description; and the 3D packages that instance it.
pub fn read_footprint(xml: &str, library: Option<&str>, name: &str) -> Result<serde_json::Value, LbrError> {
use serde_json::json;
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
let el = |n: &roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;
let layer_names: BTreeMap<String, String> = doc.root_element().descendants().filter(|n| el(n, "layer"))
.map(|l| (l.attribute("number").unwrap_or("").to_string(), l.attribute("name").unwrap_or("").to_string())).collect();
let lname = |n: &str| layer_names.get(n).cloned().unwrap_or_else(|| n.to_string());
let libs: Vec<roxmltree::Node> = doc.root_element().descendants().filter(|n| el(n, "library"))
.filter(|l| library.map(|want| l.attribute("name").map(|n| n.eq_ignore_ascii_case(want)).unwrap_or(true)).unwrap_or(true)).collect();
if libs.is_empty() { return Err(LbrError::NotALibrary(format!("no library {}", library.unwrap_or("")))); }
let pkgs: Vec<roxmltree::Node> = libs.iter().flat_map(|l| l.descendants().filter(|n| el(n, "package") && n.parent().map(|p| el(&p, "packages")).unwrap_or(false)).collect::<Vec<_>>()).collect();
let Some(pkg) = pkgs.iter().find(|p| p.attribute("name") == Some(name)).or_else(|| pkgs.iter().find(|p| p.attribute("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false))) else {
let names: Vec<&str> = pkgs.iter().filter_map(|p| p.attribute("name")).collect();
return Err(LbrError::NotALibrary(format!("no footprint {name}; the library has {}", names.join(", "))));
};
let f = |n: &roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
let rot = |n: &roxmltree::Node| n.attribute("rot").unwrap_or("R0").trim_start_matches('S').trim_start_matches('M').trim_start_matches('R').parse::<f64>().unwrap_or(0.0);
let mut pads = Vec::new();
let mut used: std::collections::BTreeSet<String> = Default::default();
let mut graphics: BTreeMap<String, usize> = BTreeMap::new();
let (mut bx, mut cy) = (Vec::<(f64, f64)>::new(), Vec::<(f64, f64)>::new());
for g in pkg.children().filter(|c| c.is_element()) {
match g.tag_name().name() {
"smd" => {
let layer = g.attribute("layer").unwrap_or("1");
let mut layers: Vec<String> = vec![lname(layer)];
if g.attribute("stop") != Some("no") { layers.push(lname(if layer == "16" { "30" } else { "29" })); }
if g.attribute("cream") != Some("no") { layers.push(lname(if layer == "16" { "32" } else { "31" })); }
let (x, y, dx, dy) = (f(&g, "x"), f(&g, "y"), f(&g, "dx"), f(&g, "dy"));
let rr = f(&g, "roundness");
bx.extend([(x - dx / 2.0, y - dy / 2.0), (x + dx / 2.0, y + dy / 2.0)]);
used.extend(layers.iter().cloned());
pads.push(json!({"number": g.attribute("name").unwrap_or(""), "type": "smd", "shape": if rr >= 100.0 { "round" } else if rr > 0.0 { "roundrect" } else { "rect" },
"at": [x, y], "sizeMm": [dx, dy], "drillMm": null, "layers": layers, "rotation": rot(&g), "roundness": rr}));
}
"pad" => {
let (x, y, d) = (f(&g, "x"), f(&g, "y"), f(&g, "drill"));
let dia = if f(&g, "diameter") > 0.0 { f(&g, "diameter") } else { d * 1.6 };
let shape = g.attribute("shape").unwrap_or("round");
let size = if shape == "long" || shape == "offset" { [dia * 2.0, dia] } else { [dia, dia] };
bx.extend([(x - size[0] / 2.0, y - size[1] / 2.0), (x + size[0] / 2.0, y + size[1] / 2.0)]);
let mut layers = vec![lname("1"), lname("16"), lname("17")];
if g.attribute("stop") != Some("no") { layers.extend([lname("29"), lname("30")]); }
used.extend(layers.iter().cloned());
pads.push(json!({"number": g.attribute("name").unwrap_or(""), "type": "thru", "shape": shape, "at": [x, y], "sizeMm": size, "drillMm": d,
"layers": layers, "rotation": rot(&g), "plated": true}));
}
"hole" => {
let (x, y, d) = (f(&g, "x"), f(&g, "y"), f(&g, "drill"));
used.insert(lname("45"));
pads.push(json!({"number": "", "type": "npth", "shape": "round", "at": [x, y], "sizeMm": [d, d], "drillMm": d, "layers": [lname("45")], "rotation": 0.0, "plated": false}));
}
"description" => {}
tag => {
if let Some(l) = g.attribute("layer") {
*graphics.entry(lname(l)).or_default() += 1;
used.insert(lname(l));
let mut pts: Vec<(f64, f64)> = match tag {
"wire" | "rectangle" => vec![(f(&g, "x1"), f(&g, "y1")), (f(&g, "x2"), f(&g, "y2"))],
"circle" => { let (x, y, r) = (f(&g, "x"), f(&g, "y"), f(&g, "radius")); vec![(x - r, y - r), (x + r, y + r)] }
_ => g.descendants().filter(|v| el(v, "vertex")).map(|v| (f(&v, "x"), f(&v, "y"))).collect(),
};
if l == "39" || l == "40" { cy.append(&mut pts); } else if l == "21" || l == "22" || l == "51" || l == "52" { bx.append(&mut pts); }
}
}
}
}
let boxed = |p: &[(f64, f64)]| -> serde_json::Value {
if p.is_empty() { return serde_json::Value::Null; }
let r = |v: f64| (v * 10000.0).round() / 10000.0;
json!([r(p.iter().map(|q| q.0).fold(f64::MAX, f64::min)), r(p.iter().map(|q| q.1).fold(f64::MAX, f64::min)),
r(p.iter().map(|q| q.0).fold(f64::MIN, f64::max)), r(p.iter().map(|q| q.1).fold(f64::MIN, f64::max))])
};
let (courtyard, measured) = if !cy.is_empty() { (boxed(&cy), "component exclude layer (39/40)") } else { (boxed(&bx), "pads and placement outline (the package has no exclude layer)") };
let lib_name = pkg.ancestors().find(|a| el(a, "library")).and_then(|l| l.attribute("name")).unwrap_or("");
let models: Vec<serde_json::Value> = libs.iter().flat_map(|l| l.descendants().filter(|n| el(n, "package3d")).collect::<Vec<_>>())
.filter(|p3| p3.descendants().any(|i| el(&i, "packageinstance") && i.attribute("name") == pkg.attribute("name")))
.map(|p3| json!({"name": p3.attribute("name").unwrap_or(""), "urn": p3.attribute("urn").filter(|u| !u.is_empty()).or(p3.attribute("wip_urn")).unwrap_or(""),
"type": p3.attribute("type").unwrap_or(""), "path": null, "offset": null, "scale": null, "rotate": null})).collect();
let smd = pads.iter().filter(|p| p["type"] == "smd").count();
let tht = pads.iter().filter(|p| p["type"] == "thru").count();
Ok(json!({
"name": pkg.attribute("name").unwrap_or(""), "library": lib_name,
"description": pkg.children().find(|c| el(c, "description")).and_then(|d| d.text()).unwrap_or("").trim(),
"pads": pads, "layers": used.into_iter().collect::<Vec<_>>(), "courtyardMm": courtyard, "courtyardFrom": measured, "graphics": graphics,
"attributes": {"mount": if tht > 0 && smd > 0 { "mixed" } else if tht > 0 { "tht" } else if smd > 0 { "smd" } else { "none" }, "excludeFromBom": null, "excludeFromPos": null, "dnp": null},
"properties": {"locallyModified": pkg.attribute("locally_modified").unwrap_or("no")},
"models": models,
}))
}
/// The standard's edit_footprint on one package of a .lbr (or a board-embedded library's text):
/// pads changed, deleted, added or renumbered (a device's connects follow a renamed pad, and a
/// deleted pad's connects are dropped and reported); graphics appended to, replaced on or deleted
/// from a layer; the description set. 3D models are Fusion cloud links (package3d URNs), so a
/// models edit is refused. Returns the new document text and what changed.
pub fn edit_footprint(xml: &str, name: &str, edits: &serde_json::Value) -> Result<(String, serde_json::Value), String> {
use serde_json::{json, Value};
let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
let layer_names: Vec<(String, String)> = {
let mut v = Vec::new();
fn walk(e: &Element, v: &mut Vec<(String, String)>) { if e.tag == "layer" { v.push((e.attr("number").unwrap_or("").into(), e.attr("name").unwrap_or("").into())); } for c in &e.children { walk(c, v); } }
walk(&root, &mut v); v
};
let layer_of = |l: &Value| -> Result<String, String> {
if let Some(n) = l.as_u64() { return Ok(n.to_string()); }
let s = l.as_str().unwrap_or("");
let std_ = [("F.Cu", "1"), ("B.Cu", "16"), ("F.SilkS", "21"), ("B.SilkS", "22"), ("F.Fab", "51"), ("B.Fab", "52"), ("F.CrtYd", "39"), ("B.CrtYd", "40"),
("F.Mask", "29"), ("B.Mask", "30"), ("F.Paste", "31"), ("B.Paste", "32"), ("Edge.Cuts", "20"), ("Dwgs.User", "48"), ("Cmts.User", "48")];
if let Some((_, n)) = std_.iter().find(|(k, _)| k.eq_ignore_ascii_case(s)) { return Ok(n.to_string()); }
if s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty() { return Ok(s.to_string()); }
layer_names.iter().find(|(_, nm)| nm.eq_ignore_ascii_case(s)).map(|(n, _)| n.clone()).ok_or_else(|| format!("unknown layer {s}"))
};
if !edits["models"].is_null() { return Err("models: Fusion links a package to its 3D model by cloud URN (package3d); edit it in Fusion's 3D package editor (fusion_show_3d_package)".into()); }
let f = |v: f64| { let s = format!("{:.4}", v); s.trim_end_matches('0').trim_end_matches('.').to_string() };
let mut changed: Vec<Value> = Vec::new();
let mut renames: Vec<(String, String)> = Vec::new();
let mut deleted: Vec<String> = Vec::new();
{
let lib = root.find_deep_mut("library").ok_or("no <library>")?;
let pkgs = lib.find_child_mut("packages").ok_or("no <packages>")?;
let pkg = pkgs.children.iter_mut().find(|p| p.tag == "package" && p.attr("name") == Some(name)).ok_or_else(|| format!("no footprint {name}"))?;
let set_attr = |e: &mut Element, k: &str, v: String| { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v, None => e.attrs.push((k.into(), v)) } };
let is_pad = |e: &Element| e.tag == "smd" || e.tag == "pad";
let make_pad = |spec: &Value, number: &str| -> Result<Element, String> {
let thru = matches!(spec["type"].as_str(), Some("thru") | Some("tht") | Some("pad"));
let mut e = Element::new(if thru { "pad" } else { "smd" });
let at = spec["at"].as_array().ok_or("a new pad needs at [x, y]")?;
e.attrs = vec![("name".into(), number.into()), ("x".into(), f(at[0].as_f64().unwrap_or(0.0))), ("y".into(), f(at[1].as_f64().unwrap_or(0.0)))];
let size = spec["sizeMm"].as_array();
if thru {
let d = spec["drillMm"].as_f64().ok_or("a through pad needs drillMm")?;
e.attrs.push(("drill".into(), f(d)));
if let Some(s) = size { e.attrs.push(("diameter".into(), f(s[0].as_f64().unwrap_or(0.0)))); }
if let Some(sh) = spec["shape"].as_str() { e.attrs.push(("shape".into(), match sh { "rect" | "square" => "square", "oval" | "long" => "long", "octagon" => "octagon", _ => "round" }.into())); }
} else {
let s = size.ok_or("an SMD pad needs sizeMm [w, h]")?;
e.attrs.push(("dx".into(), f(s[0].as_f64().unwrap_or(0.0)))); e.attrs.push(("dy".into(), f(s[1].as_f64().unwrap_or(0.0))));
let layer = spec["layers"].as_array().and_then(|l| l.first()).map(|l| if l.as_str().map(|s| s.starts_with("B.")).unwrap_or(false) { "16" } else { "1" }).unwrap_or("1");
e.attrs.push(("layer".into(), layer.into()));
if let Some(sh) = spec["shape"].as_str() { if sh != "rect" { e.attrs.push(("roundness".into(), if sh == "roundrect" { "25" } else { "100" }.into())); } }
}
if let Some(r) = spec["rotation"].as_f64() { if r != 0.0 { e.attrs.push(("rot".into(), format!("R{}", f(r)))); } }
Ok(e)
};
// pad edits
for pe in edits["pads"].as_array().cloned().unwrap_or_default() {
let num = pe["number"].as_str().map(str::to_string).or(pe["number"].as_u64().map(|n| n.to_string())).ok_or("each pad edit needs number")?;
let idx = pkg.children.iter().position(|c| is_pad(c) && c.attr("name") == Some(num.as_str())).ok_or_else(|| format!("no pad {num} in {name}"))?;
if pe["delete"].as_bool() == Some(true) { pkg.children.remove(idx); deleted.push(num.clone()); changed.push(json!({"pad": num, "what": "deleted"})); continue; }
let set = &pe["set"];
if let Some(t) = set["type"].as_str() {
let want_thru = matches!(t, "thru" | "tht" | "pad");
if want_thru != (pkg.children[idx].tag == "pad") {
let old = &pkg.children[idx];
let mut spec = set.clone();
if spec["at"].is_null() { spec["at"] = json!([old.attr("x").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0), old.attr("y").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0)]); }
let e = make_pad(&spec, &num)?;
pkg.children[idx] = Element { tail: pkg.children[idx].tail.clone(), ..e };
changed.push(json!({"pad": num, "what": format!("type {t}")}));
}
}
let e = &mut pkg.children[idx];
if let Some(at) = set["at"].as_array() { set_attr(e, "x", f(at[0].as_f64().unwrap_or(0.0))); set_attr(e, "y", f(at[1].as_f64().unwrap_or(0.0))); changed.push(json!({"pad": num, "what": "at"})); }
if let Some(s) = set["sizeMm"].as_array() {
if e.tag == "smd" { set_attr(e, "dx", f(s[0].as_f64().unwrap_or(0.0))); set_attr(e, "dy", f(s[1].as_f64().unwrap_or(0.0))); } else { set_attr(e, "diameter", f(s[0].as_f64().unwrap_or(0.0))); }
changed.push(json!({"pad": num, "what": "size"}));
}
if let Some(d) = set["drillMm"].as_f64() { if e.tag != "pad" { return Err(format!("pad {num} is SMD: drillMm needs type thru")); } set_attr(e, "drill", f(d)); changed.push(json!({"pad": num, "what": "drill"})); }
if let Some(r) = set["rotation"].as_f64() { set_attr(e, "rot", format!("R{}", f(r))); changed.push(json!({"pad": num, "what": "rotation"})); }
if let Some(sh) = set["shape"].as_str() {
if e.tag == "smd" { set_attr(e, "roundness", match sh { "rect" => "0", "roundrect" => "25", _ => "100" }.into()); }
else { set_attr(e, "shape", match sh { "rect" | "square" => "square", "oval" | "long" => "long", "octagon" => "octagon", _ => "round" }.into()); }
changed.push(json!({"pad": num, "what": "shape"}));
}
if let Some(ls) = set["layers"].as_array() { if e.tag == "smd" { let l = if ls.iter().any(|l| l.as_str().map(|s| s.starts_with("B.")).unwrap_or(false)) { "16" } else { "1" }; set_attr(e, "layer", l.into()); changed.push(json!({"pad": num, "what": "layer"})); } }
if let Some(nn) = set["newNumber"].as_str().map(str::to_string).or(set["newNumber"].as_u64().map(|n| n.to_string())) { set_attr(e, "name", nn.clone()); renames.push((num.clone(), nn.clone())); changed.push(json!({"pad": num, "what": format!("renumbered {nn}")})); }
}
// new pads
for spec in edits["addPads"].as_array().cloned().unwrap_or_default() {
let num = spec["number"].as_str().map(str::to_string).or(spec["number"].as_u64().map(|n| n.to_string())).ok_or("a new pad needs number")?;
if pkg.children.iter().any(|c| is_pad(c) && c.attr("name") == Some(num.as_str())) { return Err(format!("pad {num} already exists in {name}")); }
let e = make_pad(&spec, &num)?;
let at = pkg.children.iter().rposition(|c| is_pad(c)).map(|i| i + 1).unwrap_or(pkg.children.len());
pkg.children.insert(at, e);
changed.push(json!({"pad": num, "what": "added"}));
}
// renumbering
let rn = &edits["renumber"];
let mut map: Vec<(String, String)> = Vec::new();
if let Some(m) = rn["map"].as_object() { map = m.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or(v.to_string()))).collect(); }
else if rn["sequential"].as_bool() == Some(true) {
let start = rn["start"].as_u64().unwrap_or(1);
map = pkg.children.iter().filter(|c| is_pad(c)).enumerate().map(|(i, c)| (c.attr("name").unwrap_or("").to_string(), (start + i as u64).to_string())).collect();
}
if !map.is_empty() {
let olds: Vec<String> = pkg.children.iter().filter(|c| is_pad(c)).map(|c| c.attr("name").unwrap_or("").to_string()).collect();
for (from, _) in &map { if !olds.contains(from) { return Err(format!("renumber: no pad {from}")); } }
let finals: Vec<String> = olds.iter().map(|o| map.iter().find(|(f_, _)| f_ == o).map(|(_, t)| t.clone()).unwrap_or(o.clone())).collect();
let mut seen = std::collections::HashSet::new();
if let Some(d) = finals.iter().find(|n| !seen.insert(n.as_str())) { return Err(format!("renumber gives two pads the number {d}")); }
for c in pkg.children.iter_mut().filter(|c| is_pad(c)) {
let old = c.attr("name").unwrap_or("").to_string();
if let Some((_, to)) = map.iter().find(|(f_, _)| *f_ == old) { if *to != old { set_attr(c, "name", to.clone()); renames.push((old, to.clone())); } }
}
changed.push(json!({"what": format!("renumbered {} pads", map.len())}));
}
// graphics
for g in edits["graphics"].as_array().cloned().unwrap_or_default() {
let layer = layer_of(&g["layer"])?;
let mode = g["mode"].as_str().unwrap_or("append");
if mode == "replace" || mode == "delete" {
let before = pkg.children.len();
pkg.children.retain(|c| is_pad(c) || c.tag == "description" || c.tag == "hole" || c.attr("layer") != Some(layer.as_str()));
changed.push(json!({"layer": layer, "what": format!("{} graphics removed", before - pkg.children.len())}));
if mode == "delete" { continue; }
}
let w = f(g["widthMm"].as_f64().unwrap_or(0.12));
let mut n = 0;
for s in g["shapes"].as_array().cloned().unwrap_or_default() {
let pt = |v: &Value| -> (String, String) { (f(v[0].as_f64().unwrap_or(0.0)), f(v[1].as_f64().unwrap_or(0.0))) };
let mut e: Element;
match s["type"].as_str().unwrap_or("") {
"line" | "arc" => {
let (a, b) = (pt(&s["start"]), pt(&s["end"]));
e = Element::new("wire");
e.attrs = vec![("x1".into(), a.0), ("y1".into(), a.1), ("x2".into(), b.0), ("y2".into(), b.1), ("width".into(), w.clone()), ("layer".into(), layer.clone())];
if let Some(c) = s["angle"].as_f64() { e.attrs.push(("curve".into(), f(c))); }
}
"rect" => {
let (a, b) = (pt(&s["start"]), pt(&s["end"]));
e = Element::new("rectangle");
e.attrs = vec![("x1".into(), a.0), ("y1".into(), a.1), ("x2".into(), b.0), ("y2".into(), b.1), ("layer".into(), layer.clone())];
}
"circle" => {
let c = pt(&s["center"]);
e = Element::new("circle");
e.attrs = vec![("x".into(), c.0), ("y".into(), c.1), ("radius".into(), f(s["radius"].as_f64().unwrap_or(0.5))), ("width".into(), w.clone()), ("layer".into(), layer.clone())];
}
"poly" => {
e = Element::new("polygon");
e.attrs = vec![("width".into(), w.clone()), ("layer".into(), layer.clone())];
for p in s["points"].as_array().cloned().unwrap_or_default() { let q = pt(&p); let mut v = Element::new("vertex"); v.attrs = vec![("x".into(), q.0), ("y".into(), q.1)]; e.children.push(v); }
if e.children.len() < 3 { return Err("a poly needs three points".into()); }
}
other => return Err(format!("unknown shape type {other}")),
}
pkg.children.push(e); n += 1;
}
if n > 0 { changed.push(json!({"layer": layer, "what": format!("{n} graphics added")})); }
}
// metadata
if let Some(d) = edits["metadata"]["description"].as_str() {
match pkg.children.iter_mut().find(|c| c.tag == "description") { Some(e) => e.text = d.to_string(), None => { let mut e = Element::new("description"); e.text = d.to_string(); pkg.children.insert(0, e); } }
changed.push(json!({"what": "description"}));
}
set_attr(pkg, "locally_modified", "yes".into());
}
// devices that use the package follow renamed and deleted pads
let mut dropped: Vec<Value> = Vec::new();
fn fix(e: &mut Element, name: &str, renames: &[(String, String)], deleted: &[String], dropped: &mut Vec<Value>) {
if e.tag == "device" && e.attr("package") == Some(name) {
if let Some(cs) = e.find_child_mut("connects") {
cs.children.retain(|c| { let keep = !deleted.iter().any(|d| c.attr("pad").map(|p| p.split(' ').any(|q| q == d)).unwrap_or(false)); if !keep { dropped.push(serde_json::json!({"pin": c.attr("pin"), "pad": c.attr("pad")})); } keep });
for c in cs.children.iter_mut() {
if let Some(pad) = c.attr("pad").map(str::to_string) {
let np: Vec<String> = pad.split(' ').map(|q| renames.iter().find(|(f_, _)| f_ == q).map(|(_, t)| t.clone()).unwrap_or(q.to_string())).collect();
let v = np.join(" ");
if v != pad { if let Some(a) = c.attrs.iter_mut().find(|(k, _)| k == "pad") { a.1 = v; } }
}
}
}
}
for c in e.children.iter_mut() { fix(c, name, renames, deleted, dropped); }
}
fix(&mut root, name, &renames, &deleted, &mut dropped);
let text = root.to_xml_document();
Ok((text, json!({"changed": changed, "connectsDropped": dropped, "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>()})))
}