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
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)
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