app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
//! Altium `.PcbDoc` board parser.
//!
//! A PcbDoc is an OLE2 / Compound File. Each primitive type lives in its own
//! storage (`Tracks6`, `Vias6`, `Arcs6`, `Pads6`, `Nets6`, `Components6`,
//! `Board6`), each with a `Data` stream. Two record shapes appear:
//!
//! * TEXT streams (Nets6, Components6, Board6): `[u32 len][ASCII |KEY=VAL|…]`
//! * BINARY streams (Tracks6, Vias6, Arcs6): `[u8 type][u32 len][payload]`,
//! with a common payload prefix — layer at 0, net index at 3 (u16, 0xFFFF =
//! no net), component at 7 — and coordinates from offset 13 as i32 in
//! Altium's internal unit of 1/10000 mil.
//!
//! * Pads6 is the odd one: record type 0x02 followed by SIX length-prefixed
//! sub-blocks; block 0 is the designator pstring and block 4 the geometry.
//!
//! Altium is Y-up, we are Y-down, so every Y is negated.
//!
//! Like the EAGLE path there is no headless Altium renderer (Altium has no CLI),
//! so these boards use our own self-render with the same interaction overlay.
use crate::pcb_render::{Arc, Board, Comp, Gfx, Pad, Track, Via, ZonePoly};
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{Cursor, Read};
/// Altium internal unit (1/10000 mil) -> mm
const U: f64 = 2.54e-6;
pub fn looks_like_altium(bytes: &[u8]) -> bool {
bytes.len() > 8 && bytes[..8] == [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
}
fn i32_at(b: &[u8], o: usize) -> i32 {
if o + 4 > b.len() { return 0; }
i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn u32_at(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() { return 0; }
u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn u16_at(b: &[u8], o: usize) -> u16 {
if o + 2 > b.len() { return 0; }
u16::from_le_bytes([b[o], b[o + 1]])
}
fn f64_at(b: &[u8], o: usize) -> f64 {
if o + 8 > b.len() { return 0.0; }
let mut a = [0u8; 8];
a.copy_from_slice(&b[o..o + 8]);
f64::from_le_bytes(a)
}
fn mm(v: i32) -> f64 { v as f64 * U }
/// `|KEY=VAL|KEY=VAL|` -> map (keys upper-cased by Altium already)
fn kv(rec: &str) -> HashMap<String, String> {
rec.trim_matches('|')
.split('|')
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_uppercase(), v.to_string()))
.collect()
}
/// Altium writes lengths like "10909.6457mil" or "1.5mm".
fn dim(s: &str) -> f64 {
let t = s.trim();
if let Some(v) = t.strip_suffix("mil") { v.trim().parse::<f64>().unwrap_or(0.0) * 0.0254 }
else if let Some(v) = t.strip_suffix("mm") { v.trim().parse::<f64>().unwrap_or(0.0) }
else { t.parse::<f64>().unwrap_or(0.0) * U }
}
fn altium_layer(n: u8) -> &'static str {
match n {
1 => "F.Cu",
32 => "B.Cu",
2..=31 => "In.Cu",
33 => "F.SilkS",
34 => "B.SilkS",
74 => "F.Cu", // multi-layer (vias/THT pads)
56..=73 => "F.Fab", // mechanical / assembly layers
_ => "",
}
}
fn read_stream(c: &mut cfb::CompoundFile<Cursor<Vec<u8>>>, path: &str) -> Option<Vec<u8>> {
let mut s = c.open_stream(path).ok()?;
let mut buf = Vec::new();
s.read_to_end(&mut buf).ok()?;
Some(buf)
}
/// `[u32 len][bytes]*` — the ASCII property-record streams.
fn text_records(d: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut off = 0usize;
while off + 4 <= d.len() {
let ln = u32_at(d, off) as usize;
off += 4;
if ln == 0 || off + ln > d.len() { break; }
out.push(String::from_utf8_lossy(&d[off..off + ln]).trim_end_matches('\0').to_string());
off += ln;
}
out
}
/// `[u8 type][u32 len][payload]*` — the fixed-shape binary streams.
fn bin_records(d: &[u8]) -> Vec<(u8, &[u8])> {
let mut out = Vec::new();
let mut off = 0usize;
while off + 5 <= d.len() {
let t = d[off];
let ln = u32_at(d, off + 1) as usize;
off += 5;
if off + ln > d.len() { break; }
out.push((t, &d[off..off + ln]));
off += ln;
}
out
}
pub fn parse_pcbdoc(bytes: &[u8]) -> Result<Board, String> {
let mut cf = cfb::CompoundFile::open(Cursor::new(bytes.to_vec()))
.map_err(|e| format!("altium .PcbDoc: not a compound file ({e})"))?;
let mut b = Board::default();
// Mechanical-layer geometry, kept with its layer number and extent so we can
// pick out which one is the BOARD OUTLINE (see below).
let mut mech: Vec<(u8, Gfx, f64, f64, f64, f64)> = Vec::new();
// ── nets: index -> name (the binary streams reference nets by index) ────
let mut net_names: Vec<String> = Vec::new();
if let Some(d) = read_stream(&mut cf, "/Nets6/Data") {
for r in text_records(&d) {
net_names.push(kv(&r).get("NAME").cloned().unwrap_or_default());
}
}
// our model keys nets by u32 starting at 1; 0 means "no net"
for (i, n) in net_names.iter().enumerate() {
b.nets.insert(i as u32 + 1, n.clone());
}
let netid = |raw: u16| -> u32 { if raw == 0xFFFF || raw as usize >= net_names.len() { 0 } else { raw as u32 + 1 } };
// ── tracks ──────────────────────────────────────────────────────────────
if let Some(d) = read_stream(&mut cf, "/Tracks6/Data") {
for (_, p) in bin_records(&d) {
if p.len() < 33 { continue; }
let layer = altium_layer(p[0]);
if layer.is_empty() { continue; }
let (x1, y1) = (mm(i32_at(p, 13)), -mm(i32_at(p, 17)));
let (x2, y2) = (mm(i32_at(p, 21)), -mm(i32_at(p, 25)));
let w = mm(i32_at(p, 29));
let net = netid(u16_at(p, 3));
if layer.ends_with(".Cu") {
b.tracks.push(Track { x1, y1, x2, y2, width: w, layer: layer.into(), net });
} else {
let g = Gfx { d: format!("M {:.4} {:.4} L {:.4} {:.4}", x1, y1, x2, y2), width: w, closed_fill: false };
match layer {
"F.SilkS" => b.silk_f.push(g),
"B.SilkS" => b.silk_b.push(g),
_ => mech.push((p[0], g, x1.min(x2), y1.min(y2), x1.max(x2), y1.max(y2))),
}
}
}
}
// ── arcs (centre + radius + start/end angle, degrees CCW) ───────────────
if let Some(d) = read_stream(&mut cf, "/Arcs6/Data") {
for (_, p) in bin_records(&d) {
if p.len() < 45 { continue; }
let layer = altium_layer(p[0]);
if layer.is_empty() { continue; }
let (cx, cy) = (mm(i32_at(p, 13)), -mm(i32_at(p, 17)));
let r = mm(i32_at(p, 21));
let (sa, ea) = (f64_at(p, 25), f64_at(p, 33));
let w = mm(i32_at(p, 41));
let d_path = arc_path(cx, cy, r, sa, ea);
let net = netid(u16_at(p, 3));
if layer.ends_with(".Cu") {
b.arcs.push(Arc { d: d_path, layer: layer.into(), width: w, net });
} else {
let g = Gfx { d: d_path, width: w, closed_fill: false };
match layer {
"F.SilkS" => b.silk_f.push(g),
"B.SilkS" => b.silk_b.push(g),
_ => mech.push((p[0], g, cx - r, cy - r, cx + r, cy + r)),
}
}
}
}
// ── vias ────────────────────────────────────────────────────────────────
if let Some(d) = read_stream(&mut cf, "/Vias6/Data") {
for (_, p) in bin_records(&d) {
if p.len() < 29 { continue; }
b.vias.push(Via {
x: mm(i32_at(p, 13)), y: -mm(i32_at(p, 17)),
size: mm(i32_at(p, 21)), drill: mm(i32_at(p, 25)),
net: netid(u16_at(p, 3)),
});
}
}
// ── copper pours ────────────────────────────────────────────────────────
// Polygons6 (TEXT) holds each pour's net; Regions6 (BINARY) holds the poured
// outlines and references its parent by polygon index at payload offset 5.
// The region's OWN net field is always 0xFFFF, so the net must come from here.
let mut poly_net: Vec<u32> = Vec::new();
if let Some(d) = read_stream(&mut cf, "/Polygons6/Data") {
for r in text_records(&d) {
let m = kv(&r);
let raw = m.get("NET").map(|v| v.trim_end_matches('\0').trim().to_string()).unwrap_or_default();
poly_net.push(raw.parse::<u16>().map(netid).unwrap_or(0));
}
}
if let Some(d) = read_stream(&mut cf, "/Regions6/Data") {
for (_, p) in bin_records(&d) {
if p.len() < 22 { continue; }
let layer = altium_layer(p[0]);
if !layer.ends_with(".Cu") { continue; }
let poly = u16_at(p, 5) as usize;
let net = poly_net.get(poly).copied().unwrap_or(0);
// [u32 kvlen][kv ascii][u32 vertex count][(f64 x, f64 y) * count]
let kvlen = u32_at(p, 18) as usize;
let mut q = 22 + kvlen;
if q + 4 > p.len() { continue; }
let count = u32_at(p, q) as usize;
q += 4;
if count < 3 || q + count * 16 > p.len() { continue; }
let mut d_path = String::new();
for k in 0..count {
let x = f64_at(p, q + k * 16) * U;
let y = -f64_at(p, q + k * 16 + 8) * U;
let _ = write!(d_path, "{} {:.4} {:.4} ", if k == 0 { "M" } else { "L" }, x, y);
}
d_path.push('Z');
b.zones.push(ZonePoly { d: d_path, layer: layer.into(), net });
}
}
// Fills6: axis-aligned rectangular copper fills
if let Some(d) = read_stream(&mut cf, "/Fills6/Data") {
for (_, p) in bin_records(&d) {
if p.len() < 29 { continue; }
let layer = altium_layer(p[0]);
if !layer.ends_with(".Cu") { continue; }
let (x1, y1) = (mm(i32_at(p, 13)), -mm(i32_at(p, 17)));
let (x2, y2) = (mm(i32_at(p, 21)), -mm(i32_at(p, 25)));
b.zones.push(ZonePoly {
d: format!("M {x1:.4} {y1:.4} L {x2:.4} {y1:.4} L {x2:.4} {y2:.4} L {x1:.4} {y2:.4} Z"),
layer: layer.into(),
net: netid(u16_at(p, 3)),
});
}
}
// ── pads (record 0x02, six length-prefixed sub-blocks) ──────────────────
let mut pad_by_comp: HashMap<u16, Vec<usize>> = HashMap::new();
if let Some(d) = read_stream(&mut cf, "/Pads6/Data") {
let mut off = 0usize;
while off < d.len() {
let rt = d[off];
off += 1;
let nblocks = match rt { 0x02 => 6, 0x05 => 2, 0x01 | 0x03 | 0x04 | 0x06 | 0x09 | 0x0B | 0x0C => 1, _ => break };
let mut blocks: Vec<&[u8]> = Vec::with_capacity(nblocks);
let mut ok = true;
for _ in 0..nblocks {
if off + 4 > d.len() { ok = false; break; }
let bl = u32_at(&d, off) as usize;
off += 4;
if off + bl > d.len() { ok = false; break; }
blocks.push(&d[off..off + bl]);
off += bl;
}
if !ok { break; }
if rt != 0x02 || blocks.len() < 5 { continue; }
let designator = {
let b0 = blocks[0];
if b0.is_empty() { String::new() }
else { let n = b0[0] as usize; String::from_utf8_lossy(&b0[1..(1 + n).min(b0.len())]).to_string() }
};
let pd = blocks[4];
if pd.len() < 50 { continue; }
let layer = pd[0];
let net = netid(u16_at(pd, 3));
let comp = u16_at(pd, 7);
let x = mm(i32_at(pd, 13));
let y = -mm(i32_at(pd, 17));
let w = u32_at(pd, 21) as f64 * U;
let h = u32_at(pd, 25) as f64 * U;
let hole = u32_at(pd, 45) as f64 * U;
// Altium shape byte: 1 Round, 2 Rectangular, 3 Octagonal, 4 RoundedRect.
// A "Round" pad is only a CIRCLE when it's square — otherwise it's a
// stadium (e.g. the SOT-223's 1.31x3.24 tab), which read as a tiny
// circle when this mapped 1 => circle unconditionally.
let square = (w - h).abs() < 0.01;
let shape = match pd[49] {
1 => if square { "circle" } else { "oval" },
2 => "rect",
3 | 4 => "roundrect",
_ => "rect",
};
// Rotation is a f64 in BLOCK 4 at offset 52 (not block 5, which is
// usually absent or holds a subnormal). Reading the wrong block left
// every rotated pad unrotated — e.g. the SOT-223's 270-degree tab and
// pins rendered with width/height transposed.
let rot = {
let r = f64_at(pd, 52);
if r.is_finite() && r.abs() <= 360.0 { r } else { 0.0 }
};
let side = if layer == 32 { 'B' } else { 'F' };
let mount = if hole > 0.0 { "thru_hole" } else { "smd" };
pad_by_comp.entry(comp).or_default().push(b.pads.len());
b.pads.push(Pad {
reference: String::new(), // filled in from Components6 below
num: designator, net, x, y, rot: -rot, w, h,
shape: shape.into(), drill: hole, mount: mount.into(), side,
});
}
}
// ── components: designator + placement; also names the pads ─────────────
if let Some(d) = read_stream(&mut cf, "/Components6/Data") {
for (i, r) in text_records(&d).iter().enumerate() {
let m = kv(r);
let reference = m.get("SOURCEDESIGNATOR").or_else(|| m.get("NAME"))
.cloned().unwrap_or_else(|| format!("C{i}"));
let x = m.get("X").map(|v| dim(v)).unwrap_or(0.0);
let y = -m.get("Y").map(|v| dim(v)).unwrap_or(0.0);
let side = if m.get("LAYER").map(|l| l.eq_ignore_ascii_case("BOTTOM")).unwrap_or(false) { "bottom" } else { "top" };
// adopt this component's pads (they carry the component index)
let mut bbox = [x - 0.5, y - 0.5, x + 0.5, y + 0.5];
let idxs = pad_by_comp.get(&(i as u16)).cloned().unwrap_or_default();
if !idxs.is_empty() {
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &pi in &idxs {
let p = &mut b.pads[pi];
p.reference = reference.clone();
mnx = mnx.min(p.x - p.w / 2.0); mxx = mxx.max(p.x + p.w / 2.0);
mny = mny.min(p.y - p.h / 2.0); mxy = mxy.max(p.y + p.h / 2.0);
}
bbox = [mnx, mny, mxx, mxy];
}
b.comps.push(Comp {
reference,
value: m.get("COMMENT").cloned().unwrap_or_default(),
footprint: m.get("PATTERN").cloned().unwrap_or_default(),
side: side.into(),
mpn: String::new(), lcsc: String::new(),
x, y, bbox, pad_count: idxs.len(),
});
}
}
// ── board outline ───────────────────────────────────────────────────────
// Altium keeps the REAL outline as ordinary geometry on a mechanical layer,
// arcs and all — that's the properly filleted shape. `Board6`'s VX/VY vertex
// list describes the same outline but its fillets live in separate KIND/CX/
// CY/R/SA/EA fields, and chaining those back into one path is fiddly; drawing
// just the vertices chamfers every rounded corner. So prefer the mechanical
// outline: pick the mech layer whose geometry spans the largest area (the
// outline bounds everything else) and promote it to the edge layer.
let mut per_layer: HashMap<u8, (f64, f64, f64, f64, usize)> = HashMap::new();
for (ly, _, x0, y0, x1, y1) in &mech {
let e = per_layer.entry(*ly).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN, 0));
e.0 = e.0.min(*x0); e.1 = e.1.min(*y0); e.2 = e.2.max(*x1); e.3 = e.3.max(*y1); e.4 += 1;
}
// Pick the TIGHTEST mech layer that still bounds all the copper. Taking the
// largest instead grabs the drawing frame / title block on boards that have
// one (SL1's A3 sheet border ballooned the board to 395x283mm).
let (mut cx0, mut cy0, mut cx1, mut cy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut fit_cu = |x: f64, y: f64| { cx0 = cx0.min(x); cy0 = cy0.min(y); cx1 = cx1.max(x); cy1 = cy1.max(y); };
for p in &b.pads { fit_cu(p.x - p.w / 2.0, p.y - p.h / 2.0); fit_cu(p.x + p.w / 2.0, p.y + p.h / 2.0); }
for t in &b.tracks { fit_cu(t.x1, t.y1); fit_cu(t.x2, t.y2); }
for v in &b.vias { fit_cu(v.x - v.size / 2.0, v.y - v.size / 2.0); fit_cu(v.x + v.size / 2.0, v.y + v.size / 2.0); }
let have_cu = cx0 <= cx1;
let area = |v: &(f64, f64, f64, f64, usize)| (v.2 - v.0) * (v.3 - v.1);
let tol = 2.0; // mm of slack: the outline sits slightly outside the copper
let bounds_copper = |v: &(f64, f64, f64, f64, usize)| {
!have_cu || (v.0 <= cx0 + tol && v.1 <= cy0 + tol && v.2 >= cx1 - tol && v.3 >= cy1 - tol)
};
let cands: Vec<_> = per_layer.iter().filter(|(_, v)| v.4 >= 4 && v.2 > v.0 && v.3 > v.1).collect();
let outline_layer = cands.iter()
.filter(|(_, v)| bounds_copper(v))
.min_by(|a, b| area(a.1).partial_cmp(&area(b.1)).unwrap_or(std::cmp::Ordering::Equal))
.or_else(|| cands.iter().max_by(|a, b| area(a.1).partial_cmp(&area(b.1)).unwrap_or(std::cmp::Ordering::Equal)))
.map(|(k, _)| **k);
for (ly, g, ..) in mech {
if Some(ly) == outline_layer { b.edges.push(g); } else { b.fab_f.push(g); }
}
if b.pads.is_empty() && b.tracks.is_empty() && b.comps.is_empty() {
return Err("altium .PcbDoc: no primitives decoded".into());
}
Ok(b)
}
/// Centre/radius/angles -> SVG arc. Altium angles are degrees CCW from +X, and
/// our Y is already flipped, so the sweep direction inverts.
fn arc_path(cx: f64, cy: f64, r: f64, sa: f64, ea: f64) -> String {
let mut sweep = ea - sa;
while sweep <= 0.0 { sweep += 360.0; }
if sweep >= 359.9 {
// full circle — two half arcs (a single arc command can't close)
return format!("M {:.4} {:.4} A {r:.4} {r:.4} 0 1 0 {:.4} {:.4} A {r:.4} {r:.4} 0 1 0 {:.4} {:.4} Z",
cx - r, cy, cx + r, cy, cx - r, cy);
}
let pt = |deg: f64| (cx + r * deg.to_radians().cos(), cy - r * deg.to_radians().sin());
let (x1, y1) = pt(sa);
let (x2, y2) = pt(ea);
let large = if sweep > 180.0 { 1 } else { 0 };
format!("M {:.4} {:.4} A {r:.4} {r:.4} 0 {} 0 {:.4} {:.4}", x1, y1, large, x2, y2)
}