← Commit history

Embed STEP bodies in the Altium export (wiki #22)

Colby Knox ·af731d38a9 ·1mo ago ·parent 4fe9a1c
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());         }