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
1.12.0 (unreleased source): board model to the EDA Bridge Standard; layer_stack, netlist, read_sheet_file, bodies; stan…
9c9f01a
16d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
//! A schematic read straight from its file, without Fusion: `fusion_read_sheet_file` (the parts of
//! an EAGLE .sch with their pins and each pin's electrical type, the EDA Bridge Standard's sheet
//! parts) and `fusion_netlist {schPath}` (the compiled netlist by pad: Designator.Pad, the same
//! form the board netlist uses). A Fusion .fsch is a zip with the .sch inside; both are read.
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Read;
use std::time::Instant;
/// The .sch XML from a path: the file itself, or the .sch inside a Fusion .fsch container.
fn sch_xml(path: &str) -> Result<String, Value> {
let bytes = std::fs::read(path).map_err(|e| json!({"success": false, "errorCode": "file_not_found",
"error": format!("cannot read {path}: {e}"), "_hint": "Pass path: an EAGLE .sch or a Fusion .fsch on this machine (fusion_export_eagle_source writes one from the open design)."}))?;
if bytes.starts_with(b"PK") {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path} is a zip but not a readable Fusion container: {e}")}))?;
let names: Vec<String> = (0..z.len()).filter_map(|i| z.by_index(i).ok().map(|f| f.name().to_string())).filter(|n| n.ends_with(".sch")).collect();
let name = match names.as_slice() { [one] => one.clone(), _ => return Err(json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path} holds {} schematic files, expected one", names.len())})) };
let mut s = String::new();
z.by_name(&name).map_err(|e| json!({"success": false, "errorCode": "unsupported_document", "error": e.to_string()}))?.read_to_string(&mut s).map_err(|e| json!({"success": false, "errorCode": "unsupported_document", "error": e.to_string()}))?;
return Ok(s);
}
String::from_utf8(bytes).map_err(|_| json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path} is not an EAGLE XML schematic")}))
}
/// EAGLE pin direction as the standard's electrical type (Altium's names; supply and nc have no
/// Altium code and keep EAGLE's meaning).
fn electrical(direction: &str) -> &'static str {
match direction { "in" => "input", "out" => "output", "io" => "io", "oc" => "open_collector", "pwr" => "power",
"pas" => "passive", "hiz" => "hiz", "sup" => "supply", "nc" => "not_connected", _ => "io" }
}
struct Lib<'a> {
symbols: HashMap<(String, String), roxmltree::Node<'a, 'a>>,
devicesets: HashMap<(String, String), roxmltree::Node<'a, 'a>>,
}
fn child<'a>(n: roxmltree::Node<'a, 'a>, tag: &str) -> Option<roxmltree::Node<'a, 'a>> { n.children().find(|c| c.has_tag_name(tag)) }
fn kids<'a>(n: roxmltree::Node<'a, 'a>, tag: &'a str) -> impl Iterator<Item = roxmltree::Node<'a, 'a>> + 'a { n.children().filter(move |c| c.has_tag_name(tag)) }
fn attr(n: roxmltree::Node, k: &str) -> String { n.attribute(k).unwrap_or("").to_string() }
fn libraries<'a>(sch: roxmltree::Node<'a, 'a>) -> Lib<'a> {
let mut lib = Lib { symbols: HashMap::new(), devicesets: HashMap::new() };
for l in sch.descendants().filter(|n| n.has_tag_name("library")) {
let ln = attr(l, "name");
for s in l.descendants().filter(|n| n.has_tag_name("symbol")) { lib.symbols.insert((ln.clone(), attr(s, "name")), s); }
for d in l.descendants().filter(|n| n.has_tag_name("deviceset")) { lib.devicesets.insert((ln.clone(), attr(d, "name")), d); }
}
lib
}
fn device<'a>(ds: roxmltree::Node<'a, 'a>, name: &str) -> Option<roxmltree::Node<'a, 'a>> {
ds.descendants().find(|d| d.has_tag_name("device") && d.attribute("name").unwrap_or("") == name)
}
/// (gate, pin) -> pads, from a device's connects ("1 2 3" is a pin on three pads).
fn pin_pads(dev: Option<roxmltree::Node>) -> HashMap<(String, String), Vec<String>> {
let mut m: HashMap<(String, String), Vec<String>> = HashMap::new();
if let Some(d) = dev {
for c in d.descendants().filter(|n| n.has_tag_name("connect")) {
m.entry((attr(c, "gate"), attr(c, "pin"))).or_default().extend(attr(c, "pad").split_whitespace().map(str::to_string));
}
}
m
}
const MIL: f64 = 1.0 / 0.0254;
/// Every EAGLE file opens with <!DOCTYPE eagle SYSTEM "eagle.dtd">, which roxmltree refuses unless asked.
fn parse(xml: &str) -> Result<roxmltree::Document<'_>, roxmltree::Error> {
roxmltree::Document::parse_with_options(xml, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
}
/// `fusion_read_sheet_file {path, pins?, params?}`
pub fn read_sheet_file(args: &Value) -> Value {
let t0 = Instant::now();
let path = match args["path"].as_str().or(args["filePath"].as_str()).or(args["schPath"].as_str()) {
Some(p) => p.to_string(),
None => return json!({"success": false, "errorCode": "bad_args", "error": "path (an EAGLE .sch or Fusion .fsch) is required",
"_hint": "fusion_export_eagle_source {outputPath:'C:/.../design.sch'} with the schematic in front writes one from the open design."}),
};
let xml = match sch_xml(&path) { Ok(x) => x, Err(e) => return e };
let doc = match parse(&xml) { Ok(d) => d, Err(e) => return json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path}: not well-formed XML ({e})")}) };
let Some(sch) = doc.descendants().find(|n| n.has_tag_name("schematic")) else {
return json!({"success": false, "errorCode": "not_a_schematic", "error": format!("{path} has no <schematic>: is it a board (.brd) or a library (.lbr)?")});
};
let lib = libraries(sch);
let want_pins = args["pins"].as_bool().unwrap_or(true);
let want_params = args["params"].as_bool().unwrap_or(false);
// where each part's gates sit: (sheet number, x, y, rot) of its first instance
let mut placed: HashMap<String, (usize, f64, f64, String, usize)> = HashMap::new();
let sheets: Vec<roxmltree::Node> = child(sch, "sheets").map(|s| kids(s, "sheet").collect()).unwrap_or_default();
for (i, s) in sheets.iter().enumerate() {
for inst in s.descendants().filter(|n| n.has_tag_name("instance")) {
let e = placed.entry(attr(inst, "part")).or_insert((i + 1, inst.attribute("x").and_then(|v| v.parse().ok()).unwrap_or(0.0),
inst.attribute("y").and_then(|v| v.parse().ok()).unwrap_or(0.0), inst.attribute("rot").unwrap_or("R0").to_string(), 0));
e.4 += 1;
}
}
let mut parts = Vec::new();
let mut unknown = Vec::new();
for p in child(sch, "parts").map(|ps| kids(ps, "part").collect::<Vec<_>>()).unwrap_or_default() {
let (name, ln, dsn, devn) = (attr(p, "name"), attr(p, "library"), attr(p, "deviceset"), attr(p, "device"));
let ds = lib.devicesets.get(&(ln.clone(), dsn.clone())).copied();
let mut row = Map::new();
row.insert("designator".into(), json!(name));
row.insert("libReference".into(), json!(format!("{dsn}{devn}")));
row.insert("deviceset".into(), json!(dsn));
row.insert("device".into(), json!(devn));
row.insert("comment".into(), json!(attr(p, "value")));
row.insert("description".into(), json!(ds.and_then(|d| child(d, "description")).and_then(|d| d.text()).unwrap_or("").trim().chars().take(240).collect::<String>()));
row.insert("uniqueId".into(), json!(name));
row.insert("sourceLibrary".into(), json!(ln));
let gates: Vec<roxmltree::Node> = ds.and_then(|d| child(d, "gates")).map(|g| kids(g, "gate").collect()).unwrap_or_default();
row.insert("partCount".into(), json!(gates.len()));
if let Some((sheet, x, y, rot, n)) = placed.get(&name) {
row.insert("sheet".into(), json!(sheet));
row.insert("xMil".into(), json!((x * MIL).round()));
row.insert("yMil".into(), json!((y * MIL).round()));
row.insert("xMm".into(), json!(x));
row.insert("yMm".into(), json!(y));
row.insert("orientation".into(), json!(rot));
row.insert("gatesPlaced".into(), json!(n));
}
let mpn = kids(p, "attribute").find(|a| a.attribute("name").map(|n| n.eq_ignore_ascii_case("MPN")).unwrap_or(false)).map(|a| attr(a, "value"));
if let Some(m) = mpn { row.insert("partNumber".into(), json!(m)); }
if want_params {
let mut params = Map::new();
for a in kids(p, "attribute") { params.insert(attr(a, "name"), json!(attr(a, "value"))); }
row.insert("params".into(), Value::Object(params));
}
if ds.is_none() { unknown.push(name.clone()); }
if want_pins {
let pads = pin_pads(ds.and_then(|d| device(d, &devn)));
let mut pins = Vec::new();
for g in &gates {
let Some(sym) = lib.symbols.get(&(ln.clone(), attr(*g, "symbol"))) else { continue };
for pin in sym.descendants().filter(|n| n.has_tag_name("pin")) {
let dir = pin.attribute("direction").unwrap_or("io");
let pn = attr(pin, "name");
let numbers = pads.get(&(attr(*g, "name"), pn.clone())).cloned().unwrap_or_default();
// a pin on several pads is listed once per pad; a pin on none (a symbol-only pin) has number ""
for num in if numbers.is_empty() { vec![String::new()] } else { numbers } {
pins.push(json!({"number": num, "name": pn, "gate": attr(*g, "name"), "electrical": electrical(dir), "direction": dir}));
}
}
}
row.insert("pins".into(), Value::Array(pins));
}
parts.push(Value::Object(row));
}
let modules = sch.descendants().filter(|n| n.has_tag_name("module")).count();
let mut out = json!({"success": true, "path": path, "components": parts, "count": parts.len(), "sheets": sheets.len(),
"elapsedMs": t0.elapsed().as_millis() as u64, "format": doc.root_element().attribute("version").map(|v| format!("EAGLE {v} XML")).unwrap_or_else(|| "EAGLE XML".into()),
"complete": modules == 0 && unknown.is_empty(),
"_hint": "The saved file, not the live sheet: unsaved edits in an open Fusion are not in it. Pins carry electrical (input, output, io, open_collector, passive, hiz, power, supply, not_connected) for a schematic review; nets come from fusion_netlist {schPath}."});
if modules > 0 { out["notes"] = json!([format!("{modules} hierarchical module(s): their inner parts are not listed yet (module instances read as their top-level parts only)")]); }
if !unknown.is_empty() { out["unknownDevicesets"] = json!(unknown); }
out
}
/// `fusion_netlist {schPath, net?, expect?}`: the compiled netlist from a schematic file, by pad.
pub fn netlist_from_sch(args: &Value) -> Value {
let t0 = Instant::now();
let path = args["schPath"].as_str().unwrap_or("").to_string();
let xml = match sch_xml(&path) { Ok(x) => x, Err(e) => return e };
let doc = match parse(&xml) { Ok(d) => d, Err(e) => return json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path}: {e}")}) };
let Some(sch) = doc.descendants().find(|n| n.has_tag_name("schematic")) else {
return json!({"success": false, "errorCode": "not_a_schematic", "error": format!("{path} has no <schematic>")});
};
let lib = libraries(sch);
let mut part_pads: HashMap<String, HashMap<(String, String), Vec<String>>> = HashMap::new();
for p in child(sch, "parts").map(|ps| kids(ps, "part").collect::<Vec<_>>()).unwrap_or_default() {
let ds = lib.devicesets.get(&(attr(p, "library"), attr(p, "deviceset"))).copied();
part_pads.insert(attr(p, "name"), pin_pads(ds.and_then(|d| device(d, &attr(p, "device")))));
}
let mut nets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut padless = BTreeSet::new();
for n in sch.descendants().filter(|n| n.has_tag_name("net")) {
let e = nets.entry(attr(n, "name")).or_default();
for r in n.descendants().filter(|x| x.has_tag_name("pinref")) {
let (part, gate, pin) = (attr(r, "part"), attr(r, "gate"), attr(r, "pin"));
match part_pads.get(&part).and_then(|m| m.get(&(gate.clone(), pin.clone()))) {
Some(pads) if !pads.is_empty() => { for pad in pads { e.insert(format!("{part}.{pad}")); } }
_ => { padless.insert(format!("{part}.{gate}.{pin}")); }
}
}
}
nets.retain(|_, v| !v.is_empty());
finish(nets, args, json!({"source": format!("{path} (the schematic file, by pad)"), "pinsWithoutPad": padless, "elapsedMs": t0.elapsed().as_millis() as u64}))
}
/// The netlist reply with `net` (one net) and `expect` (compare pin by pin) applied: shared by
/// the schematic-file read and the live board read.
pub fn finish(nets: BTreeMap<String, BTreeSet<String>>, args: &Value, extra: Value) -> Value {
let mut mismatches = Vec::new();
if let Some(exp) = args["expect"].as_object() {
let key = |s: &str| s.to_ascii_lowercase();
let have: HashMap<String, (&String, &BTreeSet<String>)> = nets.iter().map(|(k, v)| (key(k), (k, v))).collect();
for (n, pins) in exp {
let want: BTreeSet<String> = pins.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect();
match have.get(&key(n)) {
None => mismatches.push(json!({"net": n, "missing": want, "extra": []})),
Some((_, got)) => {
let missing: Vec<&String> = want.difference(got).collect();
let extra: Vec<&String> = got.difference(&want).collect();
if !missing.is_empty() || !extra.is_empty() { mismatches.push(json!({"net": n, "missing": missing, "extra": extra})); }
}
}
}
let expected: BTreeSet<String> = exp.keys().map(|k| key(k)).collect();
for k in nets.keys() { if !expected.contains(&key(k)) { mismatches.push(json!({"net": k, "unexpected": nets[k]})); } }
}
let shown: BTreeMap<&String, &BTreeSet<String>> = match args["net"].as_str() {
Some(one) => nets.iter().filter(|(k, _)| k.eq_ignore_ascii_case(one)).collect(),
None => nets.iter().collect(),
};
if let Some(one) = args["net"].as_str() {
if shown.is_empty() { return json!({"success": false, "errorCode": "no_such_net", "error": format!("no net named {one}"), "netCount": nets.len()}); }
}
let mut out = json!({"success": true, "mutated": false, "nets": shown, "netCount": nets.len()});
if args["expect"].is_object() { out["mismatches"] = json!(mismatches); out["ok"] = json!(mismatches.is_empty()); }
if let Value::Object(m) = extra { for (k, v) in m { out[k] = v; } }
out["_hint"] = json!(if args["expect"].is_object() { "mismatches lists each net whose pads differ from what you expected (missing, extra) and nets you did not expect." } else { "Every net with its pads as Designator.Pad. Pass expect {net:[\"U1.3\",...]} to compare with the circuit you meant." });
out
}
/// The board netlist reply (from the routing script) put through the same net / expect handling.
pub fn shape_board_netlist(r: Value, args: &Value) -> Value {
if r["success"].as_bool() != Some(true) || !(args["net"].is_string() || args["expect"].is_object()) { return r; }
let nets: BTreeMap<String, BTreeSet<String>> = r["nets"].as_object().map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())).collect()).unwrap_or_default();
let mut extra = r.clone();
for k in ["nets", "netCount", "_hint", "success"] { extra.as_object_mut().map(|m| m.remove(k)); }
finish(nets, args, extra)
}
#[cfg(test)]
mod tests {
use super::*;
const SCH: &str = r#"<?xml version="1.0"?>
<!DOCTYPE eagle SYSTEM "eagle.dtd">
<eagle version="9.6.2"><drawing><schematic><libraries><library name="L">
<symbols><symbol name="OPAMP"><pin name="+IN" x="0" y="0" direction="in"/><pin name="OUT" x="5" y="0" direction="out"/><pin name="V+" x="0" y="5" direction="pwr"/></symbol>
<symbol name="R"><pin name="1" x="0" y="0" direction="pas"/><pin name="2" x="5" y="0" direction="pas"/></symbol></symbols>
<devicesets><deviceset name="LM321"><description>Single op amp</description><gates><gate name="G$1" symbol="OPAMP" x="0" y="0"/></gates><devices><device name="-SOT" package="SOT23-5"><connects>
<connect gate="G$1" pin="+IN" pad="3"/><connect gate="G$1" pin="OUT" pad="1"/><connect gate="G$1" pin="V+" pad="5 6"/></connects></device></devices></deviceset>
<deviceset name="R"><gates><gate name="G$1" symbol="R" x="0" y="0"/></gates><devices><device name="0402" package="0402"><connects><connect gate="G$1" pin="1" pad="1"/><connect gate="G$1" pin="2" pad="2"/></connects></device></devices></deviceset>
</devicesets></library></libraries><parts><part name="U1" library="L" deviceset="LM321" device="-SOT" value="LM321"><attribute name="MPN" value="LM321MF"/></part>
<part name="R1" library="L" deviceset="R" device="0402" value="10k"/></parts>
<sheets><sheet><instances><instance part="U1" gate="G$1" x="25.4" y="50.8"/><instance part="R1" gate="G$1" x="0" y="0" rot="R90"/></instances>
<nets><net name="OUT"><segment><pinref part="U1" gate="G$1" pin="OUT"/><pinref part="R1" gate="G$1" pin="1"/></segment></net>
<net name="VCC"><segment><pinref part="U1" gate="G$1" pin="V+"/></segment></net></nets></sheet></sheets></schematic></drawing></eagle>"#;
fn tmp(body: &str) -> String {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let p = std::env::temp_dir().join(format!("sheet-test-{}-{n}.sch", std::process::id()));
std::fs::write(&p, body).unwrap();
p.to_string_lossy().into_owned()
}
#[test]
fn parts_with_pins_and_electrical_types() {
let r = read_sheet_file(&json!({"path": tmp(SCH), "params": true}));
assert_eq!(r["success"], true, "{r}");
assert_eq!(r["count"], 2);
let u1 = &r["components"][0];
assert_eq!(u1["designator"], "U1");
assert_eq!(u1["libReference"], "LM321-SOT");
assert_eq!(u1["comment"], "LM321");
assert_eq!(u1["partNumber"], "LM321MF");
assert_eq!(u1["xMil"], 1000.0);
let pins = u1["pins"].as_array().unwrap();
assert!(pins.contains(&json!({"number": "3", "name": "+IN", "gate": "G$1", "electrical": "input", "direction": "in"})));
assert!(pins.contains(&json!({"number": "1", "name": "OUT", "gate": "G$1", "electrical": "output", "direction": "out"})));
// a power pin on two pads is listed for each
assert_eq!(pins.iter().filter(|p| p["name"] == "V+").count(), 2);
assert_eq!(r["components"][1]["orientation"], "R90");
assert_eq!(r["complete"], true);
}
#[test]
fn netlist_by_pad_with_expect() {
let p = tmp(SCH);
let r = netlist_from_sch(&json!({"schPath": p}));
assert_eq!(r["nets"]["OUT"], json!(["R1.1", "U1.1"]));
assert_eq!(r["nets"]["VCC"], json!(["U1.5", "U1.6"]));
let r = netlist_from_sch(&json!({"schPath": p, "expect": {"out": ["U1.1", "R1.2"], "VCC": ["U1.5", "U1.6"]}}));
assert_eq!(r["ok"], false);
assert_eq!(r["mismatches"], json!([{"net": "out", "missing": ["R1.2"], "extra": ["R1.1"]}]));
let r = netlist_from_sch(&json!({"schPath": p, "net": "vcc"}));
assert_eq!(r["nets"], json!({"VCC": ["U1.5", "U1.6"]}));
assert_eq!(netlist_from_sch(&json!({"schPath": p, "net": "NOPE"}))["errorCode"], "no_such_net");
}
#[test]
fn refuses_what_is_not_a_schematic() {
assert_eq!(read_sheet_file(&json!({}))["errorCode"], "bad_args");
assert_eq!(read_sheet_file(&json!({"path": "/no/such.sch"}))["errorCode"], "file_not_found");
assert_eq!(read_sheet_file(&json!({"path": tmp("<eagle><drawing><board/></drawing></eagle>")}))["errorCode"], "not_a_schematic");
}
}