app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
Embed STEP bodies in the Altium export (wiki #22)
3 files changed
+264−6
crates/altium-codec/src/lib.rs+44−1@@ -21,7 +21,7 @@ pub use intlib::{decode_intlib, encode_intlib}; pub use kicad::{import_kicad, import_kicad_with_pin_map}; pub use kicad_export::{export_kicad_footprint, export_kicad_symbol}; pub use model::{AdomLbrPart, BodyRect, ConnectEntry, Footprint, FpGraphic, FpLayer, Graphic, Pad, Parameter, Pin, Symbol};-pub use pcblib::{decode_pcblib, encode_pcblib, encode_pcblib_many};+pub use pcblib::{decode_pcblib, encode_pcblib, encode_pcblib_many, encode_pcblib_many_with_models, EmbeddedModel}; pub use schlib::{add_symbols_to_schlib, decode_schlib_first, encode_schlib}; #[cfg(test)]@@ -47,6 +47,49 @@ mod tests { } } + #[test]+ fn embedded_step_models_land_in_the_store_and_the_body_record() {+ // wiki adom-lbr#22: the Altium lane shipped an empty model store, so+ // Altium 3D showed bare pads. Layout calibrated against the vendor+ // fixture: zlib STEP under Library/Models/<n>, metadata in+ // Library/Models/Data, count in Header, and a ComponentBody record in+ // the footprint stream referencing the MODELID.+ use crate::model::{Footprint, Pad};+ use std::io::Read;+ let pad = Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0,+ drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None };+ let fp1 = Footprint { name: "WITHMODEL".into(), pads: vec![pad.clone()], graphics: vec![] };+ let fp2 = Footprint { name: "BARE".into(), pads: vec![pad], graphics: vec![] };+ let step = b"ISO-10303-21;\r\nHEADER;\r\nENDSEC;\r\nEND-ISO-10303-21;\r\n".to_vec();+ let m = EmbeddedModel { name: "WITHMODEL.step".into(), step: step.clone(),+ rot_x: 0.0, rot_y: 0.0, rot_z: 0.0, dz_mil: 0.0, overall_height_mil: 0.0 };+ let bytes = encode_pcblib_many_with_models(&[fp1, fp2], &[Some(m), None]).expect("encode");+ let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).unwrap();+ let read = |c: &mut cfb::CompoundFile<std::io::Cursor<Vec<u8>>>, p: &str| -> Vec<u8> {+ let mut v = Vec::new();+ c.open_stream(p).unwrap().read_to_end(&mut v).unwrap();+ v+ };+ // store: count 1, metadata record, zlib payload round-trips to the STEP+ assert_eq!(read(&mut comp, "/Library/Models/Header"), 1u32.to_le_bytes().to_vec());+ let md = String::from_utf8_lossy(&read(&mut comp, "/Library/Models/Data")).to_string();+ assert!(md.contains("EMBED=TRUE") && md.contains("NAME=WITHMODEL.step"), "{md}");+ let comp0 = read(&mut comp, "/Library/Models/0");+ let mut dec = Vec::new();+ flate2::read::ZlibDecoder::new(&comp0[..]).read_to_end(&mut dec).unwrap();+ assert_eq!(dec, step, "Models/0 must zlib-decompress to the exact STEP bytes");+ // the footprint carries the body record; the model-less one does not+ let fd = read(&mut comp, "/WITHMODEL/Data");+ let ftxt = String::from_utf8_lossy(&fd).to_string();+ assert!(ftxt.contains("MODEL.EMBED=TRUE") && ftxt.contains("V7_LAYER=MECHANICAL1"), "body record missing");+ let id = md.split("ID={").nth(1).unwrap().split('}').next().unwrap();+ assert!(ftxt.contains(&format!("MODELID={{{id}}}")), "body must reference the store's MODELID");+ // primitive count includes the body+ assert_eq!(read(&mut comp, "/WITHMODEL/Header"), 2u32.to_le_bytes().to_vec());+ assert_eq!(read(&mut comp, "/BARE/Header"), 1u32.to_le_bytes().to_vec());+ assert!(!String::from_utf8_lossy(&read(&mut comp, "/BARE/Data")).contains("MODELID"));+ }+ #[test] fn long_footprint_names_truncate_the_storage_but_keep_the_pattern_name() { // OLE2 caps directory names at 31 UTF-16 units and the cfb crate
crates/altium-codec/src/pcblib.rs+174−4@@ -152,15 +152,78 @@ fn expand_fp_graphics(fp: &Footprint) -> Vec<(u32, &'static str, Vec<u8>)> { // --------------------------------------------------------------------------- /// Encode one footprint into a clean `.PcbLib` byte image.+/// A STEP body to embed with a footprint (wiki adom-lbr#22): the payload lands+/// zlib-compressed under `Library/Models/<n>`, its metadata in+/// `Library/Models/Data`, and the footprint gains a ComponentBody record+/// referencing the model id. Field layout calibrated byte-for-byte against a+/// vendor library (ADS1220IRVAT.PcbLib, the crate's own fixture).+#[derive(Clone)]+pub struct EmbeddedModel {+ /// Model display name, e.g. "TPS73733DCQR.step".+ pub name: String,+ /// Raw STEP file bytes (ISO-10303-21 text).+ pub step: Vec<u8>,+ pub rot_x: f64,+ pub rot_y: f64,+ pub rot_z: f64,+ pub dz_mil: f64,+ pub overall_height_mil: f64,+}++struct ModelRef<'a> {+ em: &'a EmbeddedModel,+ guid: String,+ checksum: u32,+}+ pub fn encode_pcblib(fp: &Footprint) -> Result<Vec<u8>> { encode_pcblib_many(std::slice::from_ref(fp)) } /// Encode several footprints into one `.PcbLib`. pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> {+ let none: Vec<Option<EmbeddedModel>> = fps.iter().map(|_| None).collect();+ encode_pcblib_many_with_models(fps, &none)+}++/// Encode several footprints, each with an optional embedded STEP body.+/// `models` runs parallel to `fps`.+pub fn encode_pcblib_many_with_models(fps: &[Footprint], models: &[Option<EmbeddedModel>]) -> Result<Vec<u8>> { if fps.is_empty() { bail!("no footprints to encode"); }+ if fps.len() != models.len() {+ bail!("models slice ({}) must run parallel to footprints ({})", models.len(), fps.len());+ }+ // Model ids and checksums are computed once and used in BOTH the model+ // store record and the footprint's body record; the id is deterministic+ // (hash of name + payload) so identical inputs encode identically.+ let mut refs: Vec<Option<ModelRef>> = Vec::with_capacity(models.len());+ let mut store: Vec<(String, Vec<u8>)> = Vec::new(); // (record text, compressed step)+ for m in models {+ match m {+ Some(em) => {+ let checksum = {+ let mut h = flate2::Crc::new();+ h.update(&em.step);+ h.sum()+ };+ let guid = model_guid(&em.name, &em.step);+ // The store prints the checksum SIGNED and the body record+ // prints it UNSIGNED; both renderings of one u32 (vendor+ // fixture: -1067402024 and 3227565272).+ let rec = format!(+ "EMBED=TRUE|MODELSOURCE=Undefined|ID={{{guid}}}|ROTX={:.3}|ROTY={:.3}|ROTZ={:.3}|DZ={}|CHECKSUM={}|NAME={}",+ em.rot_x, em.rot_y, em.rot_z, em.dz_mil as i64, checksum as i32, em.name+ );+ let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());+ std::io::Write::write_all(&mut enc, &em.step)?;+ store.push((rec, enc.finish()?));+ refs.push(Some(ModelRef { em, guid, checksum }));+ }+ None => refs.push(None),+ }+ } let tpl = read_all_streams(TEMPLATE)?; let pad_data_tpl = harvest_pad_data_block(&tpl)?; @@ -176,7 +239,19 @@ pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> { continue; } let patched;- let data: &[u8] = if path == "/Library/Data" {+ let data: &[u8] = if path == "/Library/Models/Data" {+ patched = {+ let mut v = Vec::new();+ for (rec, _) in &store {+ v.extend(u32_text(rec));+ }+ v+ };+ &patched+ } else if path == "/Library/Models/Header" {+ patched = (store.len() as u32).to_le_bytes().to_vec();+ &patched+ } else if path == "/Library/Data" { patched = patch_library_data_tail(bytes, fps); &patched } else if path == "/Library/ComponentParamsTOC/Data" {@@ -191,6 +266,11 @@ pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> { write_stream(&mut comp, path, data, &mut made_storages)?; } + // Embedded model payloads: one numbered stream per model.+ for (i, (_, comp_bytes)) in store.iter().enumerate() {+ write_stream(&mut comp, &format!("/Library/Models/{i}"), comp_bytes, &mut made_storages)?;+ }+ // 2) Write each component's storages. OLE2 directory names cap at 31 // UTF-16 units, and the cfb crate writes a longer name WITHOUT error // into an entry Altium and olefile both silently skip: a 37-char@@ -200,9 +280,9 @@ pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> { // footprint's own Data block, which is what readers resolve; do the // same, keeping truncated names unique. let mut taken: std::collections::BTreeSet<String> = Default::default();- for fp in fps {+ for (fp, mref) in fps.iter().zip(refs.iter()) { let storage = storage_name_31(&fp.name, &mut taken);- write_component(&mut comp, fp, &storage, &pad_data_tpl, &mut made_storages)?;+ write_component(&mut comp, fp, &storage, &pad_data_tpl, mref.as_ref(), &mut made_storages)?; } comp.flush()?;@@ -215,6 +295,7 @@ fn write_component<F: Read + Write + std::io::Seek>( fp: &Footprint, storage: &str, pad_data_tpl: &[u8],+ model: Option<&ModelRef>, made: &mut std::collections::BTreeSet<String>, ) -> Result<()> { let n = fp.pads.len();@@ -237,7 +318,12 @@ fn write_component<F: Read + Write + std::io::Seek>( for (_, _, rec) in &graphics { data.extend_from_slice(rec); }- let total = n + graphics.len(); // every record is one primitive+ // ComponentBody record referencing the embedded model, after the last+ // graphic (vendor libraries place it last too).+ if let Some(m) = model {+ data.extend(body_record(fp, m));+ }+ let total = n + graphics.len() + usize::from(model.is_some()); // every record is one primitive write_stream(comp, &format!("{base}/Data"), &data, made)?; write_stream(comp, &format!("{base}/Header"), &(total as u32).to_le_bytes(), made)?; @@ -513,6 +599,90 @@ fn harvest_pad_data_block(tpl: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>> { } /// Create any missing parent storages, then write `path` as a stream.+/// Deterministic model GUID (8-4-4-4-12 uppercase) hashed from the model name+/// and payload, so identical inputs encode identically.+fn model_guid(name: &str, step: &[u8]) -> String {+ fn mix(h: u64, b: u8) -> u64 {+ (h ^ b as u64).wrapping_mul(1099511628211)+ }+ let mut h: u64 = 1469598103934665603;+ for b in name.bytes() {+ h = mix(h, b);+ }+ for b in step.iter().take(4096) {+ h = mix(h, *b);+ }+ for b in (step.len() as u64).to_le_bytes() {+ h = mix(h, b);+ }+ let a = h;+ h = mix(h, 0xA5);+ let b = h.wrapping_mul(a | 1);+ let bytes: Vec<u8> = a.to_le_bytes().iter().chain(b.to_le_bytes().iter()).copied().collect();+ format!(+ "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",+ bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],+ bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]+ )+}++/// The ComponentBody record (type 0x0C, one sub-block), byte-calibrated against+/// the vendor fixture: an 18-byte header (layer 0x39 = Mechanical 1, flag/owner+/// bytes verbatim), a u32-length NUL-terminated text record, then the body's+/// 2D outline as [u32 npoints][npoints * (x f64, y f64)] in 1/10000 mil units.+/// The outline here is the pad bounding box with a 10 mil margin; Altium draws+/// the 3D shape from the STEP itself, the outline is the 2D projection.+fn body_record(fp: &Footprint, m: &ModelRef) -> Vec<u8> {+ const HDR: [u8; 18] = [+ 0x39, 0x0c, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,+ 0x00, 0x00, 0x00, 0x00, 0x00,+ ];+ let txt = format!(+ "V7_LAYER=MECHANICAL1|NAME= |KIND=0|SUBPOLYINDEX=-1|UNIONINDEX=0|ARCRESOLUTION=0.5mil|ISSHAPEBASED=FALSE|CAVITYHEIGHT=0mil|STANDOFFHEIGHT=0mil|OVERALLHEIGHT={oh}mil|BODYPROJECTION=0|ARCRESOLUTION=0.5mil|BODYCOLOR3D=8421504|BODYOPACITY3D=1.000|IDENTIFIER=|TEXTURE=|TEXTURECENTERX=0mil|TEXTURECENTERY=0mil|TEXTURESIZEX=0mil|TEXTURESIZEY=0mil|TEXTUREROTATION= 0.00000000000000E+0000|MODELID={{{id}}}|MODEL.CHECKSUM={ck}|MODEL.EMBED=TRUE|MODEL.NAME={name}|MODEL.2D.X=0mil|MODEL.2D.Y=0mil|MODEL.2D.ROTATION=0.000|MODEL.3D.ROTX={rx:.3}|MODEL.3D.ROTY={ry:.3}|MODEL.3D.ROTZ={rz:.3}|MODEL.3D.DZ={dz}mil|MODEL.MODELTYPE=1|MODEL.MODELSOURCE=Undefined",+ oh = m.em.overall_height_mil as i64,+ id = m.guid,+ ck = m.checksum,+ name = m.em.name,+ rx = m.em.rot_x,+ ry = m.em.rot_y,+ rz = m.em.rot_z,+ dz = m.em.dz_mil as i64,+ );+ // pad bounding box in internal units, with a 10 mil (0.254 mm) margin+ let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in &fp.pads {+ x0 = x0.min(p.x_mm - p.w_mm / 2.0);+ x1 = x1.max(p.x_mm + p.w_mm / 2.0);+ y0 = y0.min(p.y_mm - p.h_mm / 2.0);+ y1 = y1.max(p.y_mm + p.h_mm / 2.0);+ }+ if fp.pads.is_empty() {+ (x0, y0, x1, y1) = (-1.0, -1.0, 1.0, 1.0);+ }+ let mgn = 0.254;+ let pts = [+ (x0 - mgn, y0 - mgn),+ (x1 + mgn, y0 - mgn),+ (x1 + mgn, y1 + mgn),+ (x0 - mgn, y1 + mgn),+ ];+ let mut block = HDR.to_vec();+ let mut tb = txt.into_bytes();+ tb.push(0);+ block.extend_from_slice(&(tb.len() as u32).to_le_bytes());+ block.extend_from_slice(&tb);+ block.extend_from_slice(&4u32.to_le_bytes());+ for (x, y) in pts {+ // neutral Y is down, Altium Y is up: flip, matching the pad emitter+ block.extend_from_slice(&(x * MM_TO_UNIT).to_le_bytes());+ block.extend_from_slice(&(-y * MM_TO_UNIT).to_le_bytes());+ }+ let mut rec = vec![0x0Cu8];+ rec.extend_from_slice(&(block.len() as u32).to_le_bytes());+ rec.extend_from_slice(&block);+ rec+}+ /// A CFBF-legal storage name: illegal chars replaced, truncated to 31 UTF-16 /// units, made unique among `taken` by rewriting the tail. fn storage_name_31(name: &str, taken: &mut std::collections::BTreeSet<String>) -> String {
src/main.rs+46−1@@ -3475,6 +3475,9 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or // name, so identical land patterns collapse to ONE footprint (and // ONE 3D binding downstream), mirroring the EAGLE assemble and the // 2.21.0 package-sharing rule.+ let mut models: Vec<Option<altium_codec::EmbeddedModel>> = Vec::new();+ let mut fp_index: BTreeMap<String, usize> = BTreeMap::new();+ let mut model_missing: Vec<Value> = Vec::new(); let mut fp_by_content: BTreeMap<String, String> = BTreeMap::new(); let mut fp_shared: BTreeMap<String, Vec<String>> = BTreeMap::new(); let mut fp_names: BTreeSet<String> = BTreeSet::new();@@ -3510,8 +3513,44 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or let mut content_probe = p.footprint.clone(); content_probe.name = String::new(); let key = serde_json::to_string(&content_probe).unwrap_or_default();+ // The part's STEP body (wiki adom-lbr#22): model_3d as a path+ // (relative to the JSON), else <mpn>.step beside it. A missing+ // model is a NAMED report entry, never silent.+ let json_dir = path.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));+ let step_path = p.model_3d.as_deref().filter(|m| !m.is_empty())+ .map(|m| { let mp = PathBuf::from(m); if mp.is_absolute() { mp } else { json_dir.join(mp) } })+ .filter(|mp| mp.is_file())+ .or_else(|| {+ // wiki pages store the STEP under the lowercase slug+ // stem, so match the <mpn>.step candidate by+ // case-insensitive file name.+ let want: Vec<String> = ["step", "stp"].iter()+ .map(|e| format!("{}.{e}", p.mpn).to_lowercase()).collect();+ fs::read_dir(&json_dir).ok().and_then(|rd| {+ rd.flatten()+ .map(|e| e.path())+ .find(|c| c.file_name()+ .map(|n| want.contains(&n.to_string_lossy().to_lowercase()))+ .unwrap_or(false))+ })+ });+ let part_model = step_path.and_then(|sp| match fs::read(&sp) {+ Ok(step) => Some(altium_codec::EmbeddedModel {+ name: format!("{}.step", p.mpn), step,+ rot_x: 0.0, rot_y: 0.0, rot_z: 0.0, dz_mil: 0.0, overall_height_mil: 0.0,+ }),+ Err(e) => { model_missing.push(json!({"part": p.mpn, "reason": format!("read {}: {e}", sp.display())})); None }+ });+ if part_model.is_none() && model_missing.iter().all(|m| m["part"] != json!(p.mpn.clone())) {+ model_missing.push(json!({"part": p.mpn,+ "reason": "no 3D model: model_3d is empty and no <mpn>.step sits beside the JSON"}));+ } let fp_name = if let Some(existing) = fp_by_content.get(&key) { fp_shared.entry(existing.clone()).or_default().push(p.mpn.clone());+ // A later part can supply the model its shared footprint lacks.+ if let (Some(i), Some(m)) = (fp_index.get(existing), part_model) {+ if models[*i].is_none() { models[*i] = Some(m); }+ } existing.clone() } else { let want_fp = if p.footprint.name.is_empty() { format!("{}-fp", p.mpn) } else { p.footprint.name.clone() };@@ -3524,7 +3563,9 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or fp_shared.insert(n.clone(), vec![p.mpn.clone()]); let mut f = p.footprint.clone(); f.name = n.clone();+ fp_index.insert(n.clone(), fps.len()); fps.push(f);+ models.push(part_model); n }; sym.footprint = Some(fp_name);@@ -3536,7 +3577,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or } let sch_bytes = altium_codec::encode_schlib(&symbols) .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode SchLib: {e:#}"), "skipped": skipped})); process::exit(2); });- let pcb_bytes = altium_codec::encode_pcblib_many(&fps)+ let pcb_bytes = altium_codec::encode_pcblib_many_with_models(&fps, &models) .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode PcbLib: {e:#}"), "skipped": skipped})); process::exit(2); }); let base = out.with_extension(""); let sp = base.with_extension("SchLib");@@ -3554,6 +3595,10 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or "deduped": deduped, "renamed": renamed, "skipped": skipped,+ "models": {+ "embedded": models.iter().filter(|m| m.is_some()).count(),+ "missing": model_missing,+ }, "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent." })).unwrap()); }