← Commit history

2.43.0: Altium symbols carry the KiCad group captions and the chip outline fitted between the pin-name columns; designator above / comment below the body; stroke width mapped from the source (<=8 mil -> Smallest hairline); assemble-altium --no-dedupe (John's ToF demo, 2026-09-19)

John Lauer ·d60f32d53b ·1mo ago ·parent b8e2b53
10 files changed +197−25
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.42.0"+version = "2.43.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
SKILL.md+13
@@ -41,6 +41,19 @@ adom-lbr export-kicad   part.adom-lbr.json --out-dir .                      # � adom-lbr add-to-schlib  existing.SchLib part.adom-lbr.json --out lib.SchLib # merge a symbol into a library ``` +**Altium symbols look like the KiCad/Fusion ones (2.43.0).** `import-kicad` keeps the+symbol's text labels (adom-symbol's bold group captions such as I2C / Power / Ground /+Control, with justification) and the SchLib writer draws them; the chip-thumbnailer+outline is fitted into the lane between the pin-name columns so it never overlaps a+pin name; the designator is centred above the body and the part number is the+Comment centred below it (Value stays a hidden parameter). Strokes map from the+source width: <= 8 mil becomes Altium's Smallest (hairline), which is what the+thumbnailer's 6.5 mil outline needs to render crisp (at Small it smears into a ~4 px+stroke), 10 mil stays Small. Multi-part libraries: `assemble-altium --no-dedupe`+keeps one footprint PER PART even when land patterns match, so each part carries its+own STEP body and its symbol links its own footprint name (the ToF trio VL53L5CX /+L7CX / L8CX share a pattern but not a body).+ **What carries across (both ways):** symbol pins + body graphics; footprint pads; **footprint silk / courtyard / fab graphics with their layers**; the pin↔pad map; the footprint link. Value/Manufacturer/MPN/Package become Altium component
VERSION+1−1
@@ -1 +1 @@-2.42.0+2.43.0
crates/altium-codec/src/altium_decode.rs+2
@@ -33,6 +33,7 @@ pub fn decode_altium(schlib: Option<&[u8]>, pcblib: Option<&[u8]>, mpn: &str) ->             pins: vec![],             body: None,             graphics: vec![],+            labels: Vec::new(),             designator_prefix: Some("U".into()),             footprint: None,             parameters: vec![],@@ -259,6 +260,7 @@ fn decode_schlib_symbol(bytes: &[u8], want: &str) -> Result<Symbol> {         pins,         body: None,         graphics,+        labels: Vec::new(),         designator_prefix,         footprint,         parameters,
crates/altium-codec/src/kicad.rs+32−1
@@ -182,6 +182,7 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {         pins,         body: None,         graphics: import_symbol_graphics(drawing),+        labels: import_symbol_labels(drawing),         designator_prefix: Some(prefix),         footprint: None,         // Source properties become parameters with their visibility kept, so@@ -201,8 +202,35 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {     }) } +/// Import a KiCad symbol's `(text ...)` captions (adom-symbol's pin-group headings) as+/// [`Label`]s in mils. Angle is tenths of a degree in KiCad text items when > 360.+fn import_symbol_labels(root: &Sx) -> Vec<crate::model::Label> {+    let to_mil = |mm: f64| mm / MM_PER_MIL;+    let mut out = Vec::new();+    let mut texts = Vec::new();+    root.collect("text", &mut texts);+    for g in texts {+        let text = match g.atom_at(1) { Some(s) => s.trim_matches('"').to_string(), None => continue };+        if text.is_empty() { continue; }+        let at = g.child("at").map(|c| c.nums()).unwrap_or_default();+        if at.len() < 2 { continue; }+        let raw = at.get(2).copied().unwrap_or(0.0);+        let deg = if raw.abs() > 360.0 { raw / 10.0 } else { raw };+        let angle = (((deg.round() as i64) % 360 + 360) % 360) as u32;+        let effects = g.child("effects");+        let font = effects.and_then(|e| e.child("font"));+        let size_mil = font.and_then(|f| f.child("size")).and_then(|s| s.nums().first().copied()).map(to_mil).unwrap_or(50.0);+        let bold = font.map(|f| f.items().iter().any(|i| matches!(i, Sx::Atom(a) if a == "bold"))).unwrap_or(false);+        let justify_right = effects.and_then(|e| e.child("justify")).map(|j| j.items().iter().any(|i| matches!(i, Sx::Atom(a) if a == "right"))).unwrap_or(false);+        out.push(crate::model::Label { text, x: to_mil(at[0]), y: to_mil(at[1]), angle, justify_right, bold, size_mil });+    }+    out+}+ /// Import a KiCad symbol's body graphics (polyline / rectangle / circle / arc)-/// into neutral [`Graphic`]s, in mils. Generic — not part-specific.+/// into neutral [`Graphic`]s, in mils. Generic — not part-specific. Rectangles come+/// FIRST: Altium draws records in order and a solid body rectangle emitted after the+/// chip outline paints over it (measured 2026-09-19 on VL53L8CX). fn import_symbol_graphics(root: &Sx) -> Vec<crate::model::Graphic> {     use crate::model::Graphic;     let to_mil = |mm: f64| mm / MM_PER_MIL;@@ -294,6 +322,9 @@ fn import_symbol_graphics(root: &Sx) -> Vec<crate::model::Graphic> {             }         }     }+    let (rects, rest): (Vec<_>, Vec<_>) = out.into_iter().partition(|g| matches!(g, Graphic::Rect { .. }));+    let mut out = rects;+    out.extend(rest);     out } 
crates/altium-codec/src/lib.rs+1−1
@@ -20,7 +20,7 @@ pub use altium_decode::{decode_altium, decode_intlib_part}; 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 model::{AdomLbrPart, BodyRect, ConnectEntry, Footprint, FpGraphic, FpLayer, Graphic, Label, Pad, Parameter, Pin, Symbol}; 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}; 
crates/altium-codec/src/model.rs+28
@@ -34,6 +34,11 @@ pub struct Symbol {     /// Manufacturer, MPN. Shown in the component's Properties panel.     #[serde(default)]     pub parameters: Vec<Parameter>,+    /// Free text on the symbol body (KiCad `(text ...)` items): adom-symbol's pin-group+    /// captions ("Power", "I2C", ...). Mils, KiCad frame; drawn as Altium RECORD=4 labels+    /// and EAGLE texts so the grouping survives every export (John, 2026-09-19).+    #[serde(default, skip_serializing_if = "Vec::is_empty")]+    pub labels: Vec<Label>,     /// Whether pin numbers are hidden on the symbol (KiCad `(pin_numbers     /// (hide yes))`). `None` → the exporter picks a passive-friendly default.     #[serde(default)]@@ -46,6 +51,25 @@ pub struct Symbol { /// A component parameter / property field (Altium RECORD=41). Appears in the /// component's Properties. `visible=false` → the field exists but isn't drawn /// on the symbol (Altium `IsHidden=T`), which is the norm for metadata.+/// A text caption on the symbol body (a pin-group heading), mils.+#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct Label {+    pub text: String,+    pub x: f64,+    pub y: f64,+    /// 0 / 90 / 180 / 270 degrees.+    #[serde(default)]+    pub angle: u32,+    /// KiCad `(justify right)`: the anchored end is the right end of the text.+    #[serde(default)]+    pub justify_right: bool,+    #[serde(default)]+    pub bold: bool,+    /// Text height in mils (KiCad font size).+    #[serde(default)]+    pub size_mil: f64,+}+ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Parameter {     /// Property name (e.g. "Value", "Manufacturer").@@ -138,6 +162,10 @@ impl Symbol {             p.x -= cx as i32;             p.y -= cy as i32;         }+        for l in &mut self.labels {+            l.x -= cx;+            l.y -= cy;+        }         for g in &mut self.graphics {             match g {                 Graphic::Polyline { points, .. } => {
crates/altium-codec/src/schlib.rs+111−18
@@ -304,8 +304,8 @@ fn u16_text_record(text: &str) -> Result<Vec<u8>> { fn build_file_header(symbols: &[(String, &Symbol)]) -> Vec<u8> {     let mut s = String::from(         "|HEADER=Protel for Windows - Schematic Library Editor Binary File Version 5.0\-|Weight=0|MinorVersion=9|UniqueID=ADOMSFHX|FontIdCount=1|Size1=10\-|FontName1=Times New Roman|UseMBCS=T|IsBOC=T|SheetStyle=9|BorderOn=T\+|Weight=0|MinorVersion=9|UniqueID=ADOMSFHX|FontIdCount=2|Size1=10\+|FontName1=Times New Roman|Size2=8|Bold2=T|FontName2=Times New Roman|UseMBCS=T|IsBOC=T|SheetStyle=9|BorderOn=T\ |SheetNumberSpaceSize=12|AreaColor=16317695|SnapGridOn=T|SnapGridSize=10\ |VisibleGridOn=T|VisibleGridSize=10|CustomX=18000|CustomY=18000|UseCustomSheet=T\ |ReferenceZonesOn=T|Display_Unit=0",@@ -338,6 +338,17 @@ fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {      // Body: emit the symbol's own graphics if it has any; otherwise a default     // body rectangle. Either way these are the records between header and pins.+    // The body box the labels and the designator/comment are placed against: the+    // first Rect graphic when there is one (adom-symbol's body), else `body`.+    let bbox = sym.graphics.iter().find_map(|g| match g {+        Graphic::Rect { x1, y1, x2, y2, .. } => Some((x1.min(*x2), y1.min(*y2), x1.max(*x2), y1.max(*y2))),+        _ => None,+    }).unwrap_or((body.x1.min(body.x2) as f64, body.y1.min(body.y2) as f64, body.x1.max(body.x2) as f64, body.y1.max(body.y2) as f64));+    // The chip drawing (every polyline) must sit in the lane between the pin-name+    // columns. KiCad laid it out for KiCad's font; Altium's Times New Roman 10 pt+    // names are wider, so measure the lane from the pins and fit the art into it+    // (uniform scale about its centre, then centred in the lane). John, 2026-09-19.+    let graphics = fit_art_to_lane(&sym.graphics, &sym.pins, bbox);     if sym.graphics.is_empty() {         out.extend(u16_text_record(&format!(             "|RECORD=14|OwnerPartId=1|Location.X={x1}|Location.Y={y1}|Corner.X={x2}|Corner.Y={y2}\@@ -349,10 +360,20 @@ fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {             uid2 = pseudo_uid(&sym.name, 14),         ))?);     } else {-        for (gi, g) in sym.graphics.iter().enumerate() {+        for (gi, g) in graphics.iter().enumerate() {             out.extend(emit_graphic(g, &sym.name, gi)?);         }     }+    // RECORD=4 — pin-group captions (adom-symbol's "Power" / "I2C" headings).+    for (li, l) in sym.labels.iter().enumerate() {+        let just = match (l.justify_right, l.angle) { (true, _) => 5, (false, _) => 3 };+        let orient = ((l.angle / 90) % 4) as u32;+        out.extend(u16_text_record(&format!(+            "|RECORD=4|OwnerPartId=1{}{}|Color=8388608|FontID={}|Text={}|Justification={just}|Orientation={orient}|UniqueID={}",+            coord_field("Location.X", l.x), coord_field("Location.Y", l.y),+            if l.bold { 2 } else { 1 }, l.text, pseudo_uid(&sym.name, 2000 + li as u32)+        ))?);+    }      // Pin records (binary). Label visibility is symbol-level in the model;     // unknown defaults to hidden for 2-pin passives (KiCad's own R/C/L@@ -364,17 +385,23 @@ fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {         out.extend(frame_pin(encode_pin_payload(pin, i, show_name, show_designator)?));     } -    // RECORD=34 — Designator label.+    // RECORD=34 — Designator label: centred above the body (KiCad's Reference place).+    let cx = (bbox.0 + bbox.2) / 2.0;     out.extend(u16_text_record(&format!(-        "|RECORD=34|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=5|Color=8388608\-|FontID=1|Text={prefix}?|Name=Designator|ReadOnlyState=1|UniqueID={uid3}",+        "|RECORD=34|IndexInSheet=-1|OwnerPartId=-1{}{}|Color=8388608\+|FontID=1|Text={prefix}?|Name=Designator|ReadOnlyState=1|Justification=1|UniqueID={uid3}",+        coord_field("Location.X", cx), coord_field("Location.Y", bbox.3 + 60.0),         uid3 = pseudo_uid(&sym.name, 34),     ))?); -    // RECORD=41 — Comment label.+    // RECORD=41 — Comment label: the part number, centred below the body (KiCad's+    // Value place). A '*' comment on a placed part reads as unfinished.+    let comment = sym.parameters.iter().find(|p| p.name == "Value" && !p.value.is_empty())+        .map(|p| p.value.clone()).unwrap_or_else(|| "*".to_string());     out.extend(u16_text_record(&format!(-        "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=-15|Color=8388608\-|FontID=1|Text=*|Name=Comment|UniqueID={uid4}",+        "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1{}{}|Color=8388608\+|FontID=1|Text={comment}|Name=Comment|Justification=7|UniqueID={uid4}",+        coord_field("Location.X", cx), coord_field("Location.Y", bbox.1 - 60.0),         uid4 = pseudo_uid(&sym.name, 41),     ))?); @@ -383,7 +410,7 @@ fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {     // comment), the rest carry IsHidden=T like standard metadata parameters.     for (pi, param) in sym.parameters.iter().enumerate() {         let uidp = pseudo_uid(&sym.name, 4100 + pi as u32);-        let rec = if param.visible {+        let rec = if param.visible && param.name != "Value" {             let y = -25 - (pi as i32) * 10;             format!(                 "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y={y}\@@ -407,7 +434,7 @@ fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {     out.extend(u16_text_record("|RECORD=44")?);     if let Some(fp) = sym.footprint.as_deref().filter(|s| !s.is_empty()) {         // record positions: R1=0, body records, pins, R34, R41-comment, params, R44.-        let body_recs = if sym.graphics.is_empty() { 1 } else { sym.graphics.len() };+        let body_recs = (if sym.graphics.is_empty() { 1 } else { sym.graphics.len() }) + sym.labels.len();         let idx_44 = pin_count + body_recs + 3 + sym.parameters.len();         let idx_45 = idx_44 + 1;         // RECORD=45 — the PCBLIB model link. ModelName + a single datafile entry@@ -453,6 +480,50 @@ fn coord_field(key: &str, mil: f64) -> String {     } } +/// Fit the chip drawing (all polylines) into the free lane between the pin-name columns.+/// Names are estimated at 62 mil per character (Times New Roman 10 pt on the 100 mil grid)+/// starting 60 mil inside the body edge. Rects/circles/arcs pass through untouched.+fn fit_art_to_lane(graphics: &[Graphic], pins: &[crate::model::Pin], bbox: (f64, f64, f64, f64)) -> Vec<Graphic> {+    const CHAR_W: f64 = 62.0;+    const NAME_GAP: f64 = 60.0;+    const MARGIN: f64 = 50.0;+    let (mut lane_l, mut lane_r) = (bbox.0 + NAME_GAP, bbox.2 - NAME_GAP);+    for p in pins {+        let len = p.length.unwrap_or(200) as f64;+        let n = p.name.chars().count() as f64;+        match (p.rotation / 90) % 4 {+            0 => lane_l = lane_l.max(p.x as f64 + len + NAME_GAP + n * CHAR_W),   // pin on the left edge+            2 => lane_r = lane_r.min(p.x as f64 - len - NAME_GAP - n * CHAR_W),   // pin on the right edge+            _ => {}+        }+    }+    let (mut ax0, mut ay0, mut ax1, mut ay1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+    for g in graphics {+        if let Graphic::Polyline { points, .. } = g {+            for pt in points { ax0 = ax0.min(pt[0]); ay0 = ay0.min(pt[1]); ax1 = ax1.max(pt[0]); ay1 = ay1.max(pt[1]); }+        }+    }+    if !ax0.is_finite() || lane_r - lane_l < 4.0 * MARGIN {+        return graphics.to_vec();+    }+    let art_w = (ax1 - ax0).max(1.0);+    let avail = lane_r - lane_l - 2.0 * MARGIN;+    let scale = (avail / art_w).min(1.0);+    let (acx, acy) = ((ax0 + ax1) / 2.0, (ay0 + ay1) / 2.0);+    let lane_c = (lane_l + lane_r) / 2.0;+    let fits_already = ax0 >= lane_l + MARGIN && ax1 <= lane_r - MARGIN;+    if fits_already && scale >= 1.0 {+        return graphics.to_vec();+    }+    graphics.iter().map(|g| match g {+        Graphic::Polyline { points, width } => Graphic::Polyline {+            points: points.iter().map(|pt| [lane_c + (pt[0] - acx) * scale, acy + (pt[1] - acy) * scale]).collect(),+            width: *width,+        },+        other => other.clone(),+    }).collect()+}+ /// Emit one symbol graphic as its Altium record, with sub-10-mil precision via /// `_Frac` fields. Polyline→RECORD=6, Rect→RECORD=14, Circle→RECORD=11, /// Arc→RECORD=12.@@ -462,9 +533,10 @@ fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Result<Vec<u8>> {     // RGB(0,0,255) stored as R + G*256 + B*65536 = 16711680. (Was 128 = maroon.)     const BLUE: u32 = 16711680;     match g {-        Graphic::Polyline { points, .. } => {+        Graphic::Polyline { points, width } => {             let mut s = format!(-                "|RECORD=6|OwnerPartId=1|LineWidth=1|Color={BLUE}|LocationCount={}",+                "|RECORD=6|OwnerPartId=1|LineWidth={}|Color={BLUE}|LocationCount={}",+                line_width_of(*width),                 points.len()             );             for (i, p) in points.iter().enumerate() {@@ -483,25 +555,46 @@ fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Result<Vec<u8>> {             coord_field("Corner.Y", *y2),             if *filled { "T" } else { "F" }         )),-        Graphic::Circle { cx, cy, r, .. } => u16_text_record(&format!(+        Graphic::Circle { cx, cy, r, width } => u16_text_record(&format!(             "|RECORD=11|OwnerPartId=1{}{}{}{}\-|LineWidth=1|Color={BLUE}|UniqueID={uid}",+|LineWidth={}|Color={BLUE}|UniqueID={uid}",             coord_field("Location.X", *cx),             coord_field("Location.Y", *cy),             coord_field("Radius", *r),             coord_field("SecondaryRadius", *r),+            line_width_of(*width),         )),-        Graphic::Arc { cx, cy, r, start, end, .. } => u16_text_record(&format!(+        Graphic::Arc { cx, cy, r, start, end, width } => u16_text_record(&format!(             "|RECORD=12|OwnerPartId=1{}{}{}|StartAngle={:.3}|EndAngle={:.3}\-|LineWidth=1|Color={BLUE}|UniqueID={uid}",+|LineWidth={}|Color={BLUE}|UniqueID={uid}",             coord_field("Location.X", *cx),             coord_field("Location.Y", *cy),             coord_field("Radius", *r),-            start, end+            start, end,+            line_width_of(*width),         )),     } } +/// Altium's four stroke widths from a source stroke in mils: 0 Smallest (a one-pixel+/// hairline at every zoom), 1 Small (10 mil), 2 Medium (20 mil), 3 Large (30 mil).+/// The chip-thumbnailer outline art arrives at ~6.5 mil (0.167 mm); at Small it+/// rendered as a smeared ~4 px stroke in the Symbol editor (John, 2026-09-19: "make+/// the stroke 1/2 or 1/3 as thick so it's crisper"), so anything at or under 8 mil+/// becomes the hairline. KiCad's 10 mil default stays Small, the body rectangle is+/// written separately and always Small.+fn line_width_of(width_mil: f64) -> u8 {+    if width_mil <= 8.0 {+        0+    } else if width_mil <= 15.0 {+        1+    } else if width_mil <= 25.0 {+        2+    } else {+        3+    }+}+ /// Build the 36+ byte binary pin payload (before framing). fn encode_pin_payload(pin: &Pin, _idx: usize, show_name: bool, show_designator: bool) -> Result<Vec<u8>> {     let mut p = PIN_PREFIX.to_vec();
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.42.0",+  "version": "2.43.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad 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.",   "dependencies": {},
src/main.rs+7−2
@@ -304,6 +304,11 @@ enum Commands {         /// Paths to adom-lbr JSONs (one per part)         #[arg(required = true)]         parts: Vec<PathBuf>,+        /// Keep one footprint PER PART even when two parts share a land pattern+        /// (each part then carries its own 3D body and its symbol links its own+        /// footprint name). Default: identical patterns collapse to one footprint.+        #[arg(long)]+        no_dedupe: bool,     },     /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.     ExportIntlib {@@ -3629,7 +3634,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or             written_hint(&pp, fp_bytes.len(), &sha_p);         } -        Commands::AssembleAltium { out, parts } => {+        Commands::AssembleAltium { out, parts, no_dedupe } => {             use serde_json::{json, Value};             use std::collections::{BTreeMap, BTreeSet};             let mut symbols: Vec<altium_codec::Symbol> = Vec::new();@@ -3687,7 +3692,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or                 sym.name = comp_name;                 // Footprint content-dedup: same geometry = one footprint.                 let mut content_probe = p.footprint.clone();-                content_probe.name = String::new();+                content_probe.name = if no_dedupe { p.footprint.name.clone() } else { 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