app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
John Lauer
Merge PR #188: Fusion reader: analyze Fusion Electronics boards with Fusion's own fill and pad rules
8aafd48
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
//! The parts on the board: every footprint's placement and its 3D model, so the viewer can sit the
//! real chip bodies on top of the fields (a hot zone reads at a glance once U1 is standing in it).
//! Models come from the board itself: each footprint's `(model ...)` entries, resolved the way KiCad
//! does (${KIPRJMOD}, ${KICAD*_3DMODEL_DIR}, extra --models-dir folders), converted once to GLB on the
//! shared Adom STEP service (`step2glb`) and cached by content. A footprint with no model gets a
//! low box from its courtyard, marked as such; parts excluded from the BOM (test points, fiducials)
//! are left as bare copper.
use crate::sexp::{parse, Sx};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
struct Model { path: String, offset: [f64; 3], scale: [f64; 3], rotate: [f64; 3] }
#[derive(Debug, Clone)]
struct Part { reference: String, value: String, x: f64, y: f64, rot: f64, bottom: bool, models: Vec<Model>, bbox: Option<[f64; 4]>, skip: bool }
fn xyz(s: Option<&Sx>, d: [f64; 3]) -> [f64; 3] { s.and_then(|k| k.kid("xyz")).map(|x| [x.num(1).unwrap_or(d[0]), x.num(2).unwrap_or(d[1]), x.num(3).unwrap_or(d[2])]).unwrap_or(d) }
fn read_parts(board: &str) -> Result<Vec<Part>, String> {
let text = std::fs::read_to_string(board).map_err(|e| format!("{board}: {e}"))?;
if crate::eagle::is_eagle(&text) { return eagle_parts(&text); }
let root = parse(&text);
let mut out = Vec::new();
for fp in root.kids("footprint").into_iter().chain(root.kids("module")) {
let prop = |name: &str| fp.kids("property").iter().find(|p| p.atom(1) == Some(name)).and_then(|p| p.atom(2)).map(str::to_string)
.or_else(|| fp.kids("fp_text").iter().find(|t| t.atom(1) == Some(&name.to_lowercase())).and_then(|t| t.atom(2)).map(str::to_string));
let reference = prop("Reference").unwrap_or_else(|| "?".into());
let at = fp.kid("at");
let attrs: Vec<String> = fp.kid("attr").map(|a| a.list().iter().skip(1).filter_map(|x| if let Sx::Atom(s) = x { Some(s.clone()) } else { None }).collect()).unwrap_or_default();
let models = fp.kids("model").iter().filter(|m| !m.list().iter().any(|k| matches!(k, Sx::List(_)) && k.head() == "hide") && m.atom(2) != Some("hide")).filter_map(|m| {
let path = m.atom(1)?.to_string();
// KiCad 6+: (offset (xyz mm)); KiCad 5: (at (xyz inches))
let offset = if m.kid("offset").is_some() { xyz(m.kid("offset"), [0.0; 3]) } else { let a = xyz(m.kid("at"), [0.0; 3]); [a[0] * 25.4, a[1] * 25.4, a[2] * 25.4] };
Some(Model { path, offset, scale: xyz(m.kid("scale"), [1.0; 3]), rotate: xyz(m.kid("rotate"), [0.0; 3]) })
}).collect();
// the courtyard (else the fab outline) in footprint coordinates, for the box when there is no model
let mut xs = Vec::new(); let mut ys = Vec::new();
for want in ["CrtYd", "Fab"] {
for key in ["fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"] {
for g in fp.kids(key) {
if !g.kid("layer").and_then(|l| l.atom(1)).map(|l| l.ends_with(want)).unwrap_or(false) { continue; }
for k in ["start", "end", "mid"] { if let Some(p) = g.kid(k) { if let (Some(x), Some(y)) = (p.num(1), p.num(2)) { xs.push(x); ys.push(y); } } }
if let Some(pts) = g.kid("pts") { for q in pts.kids("xy") { if let (Some(x), Some(y)) = (q.num(1), q.num(2)) { xs.push(x); ys.push(y); } } }
}
}
if !xs.is_empty() { break; }
}
let bbox = if xs.len() >= 2 { Some([xs.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MAX, f64::min), xs.iter().cloned().fold(f64::MIN, f64::max), ys.iter().cloned().fold(f64::MIN, f64::max)]) } else { None };
out.push(Part {
value: prop("Value").unwrap_or_default(), x: at.and_then(|a| a.num(1)).unwrap_or(0.0), y: at.and_then(|a| a.num(2)).unwrap_or(0.0), rot: at.and_then(|a| a.num(3)).unwrap_or(0.0),
bottom: fp.kid("layer").and_then(|l| l.atom(1)) == Some("B.Cu"), models, bbox,
skip: attrs.iter().any(|a| a == "exclude_from_bom" || a == "board_only"), reference,
});
}
Ok(out)
}
/// A Fusion board binds its 3D packages by cloud urn, so its models are found by name instead: the
/// element's MPN, then its deviceset, then its package, as `<name>.step` in the --models-dir folders
/// (and ./3d, ./3dmodels beside the board). The model is placed as the Fusion bridge binds it: the
/// STEP's own origin on the footprint origin, no extra offset or rotation.
fn eagle_parts(text: &str) -> Result<Vec<Part>, String> {
Ok(crate::eagle::elements(text)?.into_iter().map(|e| Part {
reference: e.reference, value: e.value, x: e.x, y: e.y, rot: e.rot, bottom: e.bottom,
models: e.names.first().map(|n| Model { path: format!("{n}.step"), offset: [0.0; 3], scale: [1.0; 3], rotate: [0.0; 3] }).into_iter().collect(),
bbox: None, skip: false,
}).collect())
}
/// ${VAR} and $(VAR) from the environment, KIPRJMOD = the board's folder
fn expand(p: &str, board_dir: &Path) -> (String, Option<String>) {
let mut s = p.replace('\\', "/"); let mut lib_var = None;
loop {
let (open, close) = if let Some(i) = s.find("${") { (i, s[i..].find('}').map(|j| i + j)) } else if let Some(i) = s.find("$(") { (i, s[i..].find(')').map(|j| i + j)) } else { break };
let Some(close) = close else { break };
let var = s[open + 2..close].to_string();
let val = if var == "KIPRJMOD" { board_dir.display().to_string() } else { if var.contains("3DMODEL_DIR") { lib_var = Some(var.clone()); } std::env::var(&var).unwrap_or_else(|_| format!("<{var}>")) };
s = format!("{}{}{}", &s[..open], val, &s[close + 1..]);
}
(s, lib_var)
}
fn is_step(p: &Path) -> bool { matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("step" | "stp")) }
/// Where the model's STEP is on this machine, or the KiCad library name the service can fetch.
fn resolve(path: &str, board_dir: &Path, dirs: &[PathBuf]) -> Result<PathBuf, Option<String>> {
let (full, lib_var) = expand(path, board_dir);
let full = PathBuf::from(&full);
let full = if full.is_relative() { board_dir.join(full) } else { full };
// KiCad libraries ship each model as .wrl and .step: the STEP carries the real geometry and colour
let mut cands = vec![];
for ext in ["step", "stp", "STEP", "STP"] { cands.push(full.with_extension(ext)); }
let name = full.file_name().map(|n| n.to_owned());
let stem = full.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut search: Vec<PathBuf> = dirs.to_vec(); search.push(board_dir.join("3d")); search.push(board_dir.join("3dmodels")); search.push(board_dir.to_path_buf());
// the stock library on a Linux KiCad install, when the variable is not set in this shell
if let Some(rest) = lib_var.as_ref().and_then(|_| path.split_once('}').map(|x| x.1.trim_start_matches('/').to_string())) {
for ext in ["step", "stp"] { cands.push(Path::new("/usr/share/kicad/3dmodels").join(&rest).with_extension(ext)); }
}
for d in &search { if let Some(n) = &name { cands.push(d.join(n)); } for ext in ["step", "stp"] { cands.push(d.join(format!("{stem}.{ext}"))); } }
if let Some(f) = cands.into_iter().find(|c| c.is_file() && is_step(c)) { return Ok(f); }
// a stock KiCad library model the machine does not have: the service fetches it by name
let lib = lib_var.and_then(|_| { let parts: Vec<&str> = path.rsplitn(3, '/').collect(); if parts.len() >= 2 { Some(format!("{}/{}", parts[1].trim_end_matches(".3dshapes"), stem)) } else { None } });
Err(lib)
}
fn cache_dir() -> PathBuf { std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/tmp")).join(".cache/adom-fields/models") }
/// one model to a GLB in the content cache: Ok(key) or Err(why)
fn convert(src: &Result<PathBuf, Option<String>>) -> Result<String, String> {
let cache = cache_dir(); std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?;
let (key, args): (String, Vec<String>) = match src {
Ok(p) => { let bytes = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?; (format!("{:x}", Sha256::digest(&bytes))[..24].to_string(), vec!["convert".into(), p.display().to_string()]) }
Err(Some(lib)) => (format!("lib-{}", lib.replace(['/', ' '], "_")), vec!["from-library".into(), lib.clone()]),
Err(None) => return Err("model file not found".into()),
};
let glb = cache.join(format!("{key}.glb"));
if glb.is_file() && std::fs::metadata(&glb).map(|m| m.len() > 0).unwrap_or(false) { return Ok(key); }
let tmp = cache.join(format!("{key}.part.glb"));
let mut cmd = std::process::Command::new("step2glb"); cmd.args(&args).arg("-o").arg(&tmp);
let out = cmd.output().map_err(|e| format!("step2glb not runnable ({e}); install it: adom-wiki pkg install adom/adom-step2glb"))?;
if !out.status.success() || !tmp.is_file() { let _ = std::fs::remove_file(&tmp); return Err(format!("step2glb {}: {}", args[0], String::from_utf8_lossy(&out.stdout).lines().chain(String::from_utf8_lossy(&out.stderr).lines()).filter(|l| l.contains("ERROR") || l.contains("rror")).take(2).collect::<Vec<_>>().join(" "))); }
std::fs::rename(&tmp, &glb).map_err(|e| e.to_string())?;
Ok(key)
}
/// The parts of a board with their models converted into `<out>/parts/`: the `parts` block of fields.json.
pub fn build(board: &str, out: &Path, dirs: &[PathBuf], log: &mut dyn FnMut(&str)) -> Value {
let parts = match read_parts(board) { Ok(p) => p, Err(e) => return json!({"status": "error", "error": e, "items": []}) };
let board_dir = std::fs::canonicalize(board).ok().and_then(|p| p.parent().map(Path::to_path_buf)).unwrap_or_else(|| PathBuf::from("."));
// every distinct model once, a few at a time on the service
let mut srcs: Vec<(String, Result<PathBuf, Option<String>>)> = Vec::new();
for p in parts.iter().filter(|p| !p.skip) { for m in &p.models { if !srcs.iter().any(|(k, _)| *k == m.path) { srcs.push((m.path.clone(), resolve(&m.path, &board_dir, dirs))); } } }
let mut results: Vec<(String, Result<String, String>)> = Vec::new();
for chunk in srcs.chunks(4) {
let hs: Vec<_> = chunk.iter().cloned().map(|(k, s)| std::thread::spawn(move || (k, convert(&s)))).collect();
for h in hs { if let Ok(r) = h.join() { results.push(r); } }
}
let _ = std::fs::create_dir_all(out.join("parts"));
let mut failed = Vec::new();
for (k, r) in &results {
match r {
Ok(key) => { let dst = out.join("parts").join(format!("{key}.glb")); if !dst.is_file() { let _ = std::fs::copy(cache_dir().join(format!("{key}.glb")), &dst); } }
Err(e) => { log(&format!("parts: no model for {k}: {e}")); failed.push(json!({"model": k, "why": e})); }
}
}
let key_of = |path: &str| results.iter().find(|(k, _)| k == path).and_then(|(_, r)| r.as_ref().ok().cloned());
let mut items = Vec::new(); let (mut modelled, mut boxed, mut bare) = (0, 0, 0);
for p in &parts {
let models: Vec<Value> = if p.skip { vec![] } else { p.models.iter().filter_map(|m| key_of(&m.path).map(|k| json!({"glb": format!("parts/{k}.glb"), "source": m.path, "offset": m.offset, "scale": m.scale, "rotate": m.rotate}))).collect() };
let kind = if p.skip { bare += 1; "bare" } else if !models.is_empty() { modelled += 1; "model" } else if p.bbox.is_some() { boxed += 1; "box" } else { bare += 1; "bare" };
items.push(json!({"ref": p.reference, "value": p.value, "x": p.x, "y": p.y, "rotation": p.rot, "side": if p.bottom { "bottom" } else { "top" }, "kind": kind, "models": models, "bbox": p.bbox}));
}
log(&format!("parts: {modelled} with 3D models, {boxed} as courtyard boxes (no model), {bare} bare (test points and board-only)"));
json!({"status": "ok", "items": items, "modelled": modelled, "boxed": boxed, "bare": bare, "failed": failed,
"method": "Each footprint's own 3D model from the board file, placed as KiCad places it (footprint position and rotation, the model's offset, rotation and scale), converted to GLB on the Adom STEP service and cached by content. Boxes stand in where a footprint has no model."})
}
#[cfg(test)] mod tests {
use super::*;
#[test] fn expands_project_and_library_variables() {
let (s, v) = expand("${KIPRJMOD}/3d/a.step", Path::new("/p")); assert_eq!(s, "/p/3d/a.step"); assert!(v.is_none());
let (_, v) = expand("${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.wrl", Path::new("/p")); assert_eq!(v.as_deref(), Some("KICAD10_3DMODEL_DIR"));
}
#[test] fn missing_library_model_falls_back_to_service_name() {
std::env::remove_var("KICAD10_3DMODEL_DIR");
let r = resolve("${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.wrl", Path::new("/nonexistent"), &[]);
assert_eq!(r.err().flatten().as_deref(), Some("Capacitor_SMD/C_1206_3216Metric"));
}
#[test] fn reads_footprint_models_and_courtyard() {
let dir = std::env::temp_dir().join(format!("af-parts-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();
let pcb = dir.join("b.kicad_pcb");
std::fs::write(&pcb, r#"(kicad_pcb (footprint "L" (layer "F.Cu") (at 10 20 90) (property "Reference" "L1") (attr smd)
(fp_rect (start -1 -2) (end 1 2) (layer "F.CrtYd")) (model "${KIPRJMOD}/3d/l.step" (offset (xyz 0 0 0.05)) (scale (xyz 1 1 1)) (rotate (xyz -90 0 0))))
(footprint "TP" (layer "B.Cu") (at 1 2) (property "Reference" "TP1") (attr exclude_from_bom)))"#).unwrap();
let p = read_parts(pcb.to_str().unwrap()).unwrap();
assert_eq!(p[0].reference, "L1"); assert_eq!(p[0].rot, 90.0); assert_eq!(p[0].models[0].rotate, [-90.0, 0.0, 0.0]); assert_eq!(p[0].models[0].offset[2], 0.05);
assert_eq!(p[0].bbox, Some([-1.0, -2.0, 1.0, 2.0])); assert!(p[1].bottom && p[1].skip);
}
}