app
Board to Molecule
Public Made by Adomby adom
Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings plus MP1-MP4 machine pins, written back in the board's own format.
main
Colby Knox
1.1.0: every molecule gets large machine pins on the 32 mm grid; gallery rebuilt; corrected notes on kicad-to-molecule
c794385
4d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
//! Altium lane checks against real PcbDoc samples.
//!
//! Set B2M_SAMPLES to a directory holding minifoc/elkpi/pidp11/cobra.PcbDoc (any
//! *.PcbDoc found there is used); the tests skip when it is unset. The written
//! molecule is re-parsed with `kspec`, a reader written straight from KiCad's
//! Altium importer (altium_parser_pcb.cpp) that shares no code with the writer.
#![allow(dead_code)]
#[path = "../rs/src/plan.rs"]
mod plan;
#[path = "../rs/src/model.rs"]
mod model;
#[path = "../rs/src/altium/mod.rs"]
mod altium;
use std::collections::{BTreeMap, HashMap};
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
fn samples() -> Vec<PathBuf> {
let Ok(dir) = std::env::var("B2M_SAMPLES") else {
eprintln!("B2M_SAMPLES not set; skipping Altium sample tests");
return vec![];
};
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.expect("B2M_SAMPLES dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pcbdoc")))
.collect();
v.sort();
assert!(!v.is_empty(), "no .PcbDoc in B2M_SAMPLES");
v
}
type Cf = cfb::CompoundFile<Cursor<Vec<u8>>>;
fn open(p: &Path) -> Cf {
cfb::CompoundFile::open(Cursor::new(std::fs::read(p).unwrap())).unwrap()
}
fn all_entries(cf: &Cf) -> BTreeMap<String, (bool, String)> {
cf.walk()
.map(|e| (e.path().to_string_lossy().replace('\\', "/"), (e.is_stream(), e.clsid().to_string())))
.collect()
}
fn read_stream(cf: &mut Cf, p: &str) -> Vec<u8> {
let mut v = vec![];
cf.open_stream(p).unwrap().read_to_end(&mut v).unwrap();
v
}
/// A reader that follows KiCad's ALTIUM_BINARY_PARSER / APAD6 / ACOMPONENT6 /
/// ATEXT6 / ATRACK6 / AREGION6 field order exactly, with KiCad's error semantics.
mod kspec {
use std::collections::HashMap;
pub struct R<'a> {
b: &'a [u8],
pub pos: usize,
sub_end: usize,
pub err: bool,
}
impl<'a> R<'a> {
pub fn new(b: &'a [u8]) -> Self {
R { b, pos: 0, sub_end: 0, err: false }
}
pub fn remaining(&self) -> usize {
self.b.len().saturating_sub(self.pos)
}
pub fn sub_remaining(&self) -> usize {
self.sub_end.saturating_sub(self.pos)
}
fn take(&mut self, n: usize) -> &'a [u8] {
if self.remaining() < n {
self.err = true;
self.pos = self.b.len();
return &[];
}
let s = &self.b[self.pos..self.pos + n];
self.pos += n;
s
}
pub fn u8(&mut self) -> u8 {
self.take(1).first().copied().unwrap_or(0)
}
pub fn u16(&mut self) -> u16 {
let s = self.take(2);
if s.len() == 2 { u16::from_le_bytes([s[0], s[1]]) } else { 0 }
}
pub fn u32(&mut self) -> u32 {
let s = self.take(4);
if s.len() == 4 { u32::from_le_bytes(s.try_into().unwrap()) } else { 0 }
}
pub fn i32(&mut self) -> i32 {
self.u32() as i32
}
pub fn f64(&mut self) -> f64 {
let s = self.take(8);
if s.len() == 8 { f64::from_le_bytes(s.try_into().unwrap()) } else { 0.0 }
}
pub fn skip(&mut self, n: usize) {
self.take(n);
}
pub fn sub_len(&mut self) -> usize {
let n = self.u32() as usize;
self.sub_end = self.pos + n;
n
}
pub fn skip_sub(&mut self) {
if self.sub_end < self.pos {
self.err = true;
return;
}
self.pos = self.sub_end;
}
pub fn wx_string(&mut self) -> String {
let n = self.u8() as usize;
self.take(n).iter().map(|&c| c as char).collect()
}
pub fn props(&mut self) -> HashMap<String, String> {
let mut kv = HashMap::new();
let mut len = self.u32() as usize;
let binary = len & 0xff00_0000 != 0;
len &= 0x00ff_ffff;
if len > self.remaining() {
self.err = true;
return kv;
}
let s = self.take(len);
if binary || s.is_empty() {
return kv;
}
let body = if *s.last().unwrap() == 0 { &s[..s.len() - 1] } else { s };
let text: String = body.iter().map(|&c| c as char).collect();
for tok in text.split('|') {
if let Some((k, v)) = tok.split_once('=') {
kv.insert(k.trim().to_uppercase(), v.trim().to_string());
}
}
kv
}
}
pub fn mil(kv: &HashMap<String, String>, k: &str) -> i64 {
let v = kv.get(k).map(|s| s.as_str()).unwrap_or("0mil");
let num: f64 = v.strip_suffix("mil").unwrap().trim_start_matches('+').parse().unwrap();
(num * 10000.0).round() as i64
}
#[derive(Debug, Clone)]
pub struct Pad {
pub name: String,
pub layer: u8,
pub net: u16,
pub component: u16,
pub x: i32,
pub y: i32,
pub top: (i32, i32),
pub mid: (i32, i32),
pub bot: (i32, i32),
pub hole: i32,
pub shapes: (u8, u8, u8),
pub direction: f64,
pub plated: bool,
pub padmode: u8,
pub sub5_len: usize,
pub holeshape: Option<u8>,
pub inner_x: Vec<i32>,
pub alt0: Option<u8>,
pub mask_modes: (u8, u8),
pub mask_values: (i32, i32),
}
pub fn pad(r: &mut R) -> Result<Pad, String> {
if r.u8() != 2 {
return Err("Pads6 stream has invalid recordtype".into());
}
if r.sub_len() == 0 {
return Err("no subrecord1".into());
}
let name = r.wx_string();
if r.sub_remaining() != 0 {
return Err("invalid subrecord1 length".into());
}
r.skip_sub();
for _ in 0..3 {
r.sub_len();
r.skip_sub();
}
let s5 = r.sub_len();
if s5 < 110 {
return Err(format!("subrecord5 length {s5}"));
}
let layer = r.u8();
let _f1 = r.u8();
let _f2 = r.u8();
let net = r.u16();
r.skip(2);
let component = r.u16();
r.skip(4);
let x = r.i32();
let y = r.i32();
let top = (r.i32(), r.i32());
let mid = (r.i32(), r.i32());
let bot = (r.i32(), r.i32());
let hole = r.i32();
let shapes = (r.u8(), r.u8(), r.u8());
let direction = r.f64();
let plated = r.u8() != 0;
r.skip(1);
let padmode = r.u8();
r.skip(23);
let paste = r.i32();
let mask = r.i32();
r.skip(7);
let pm = r.u8();
let sm = r.u8();
r.skip(3);
if s5 == 110 {
if r.i32() != 0 {
return Err("subrecord5 + 106 nonzero".into());
}
} else {
if s5 < 114 {
return Err("subrecord5 < 114".into());
}
let _holerot = r.f64();
}
if s5 >= 120 {
let _to = r.u8();
r.skip(2);
let _from = r.u8();
}
if s5 >= 202 {
r.skip(40);
r.i32();
r.skip(32);
r.f64();
}
r.skip_sub();
let s6 = r.sub_len();
let (mut holeshape, mut inner_x, mut alt0) = (None, vec![], None);
if s6 >= 596 {
inner_x = (0..29).map(|_| r.i32()).collect();
for _ in 0..29 {
r.i32();
}
for _ in 0..29 {
r.u8();
}
r.skip(1);
holeshape = Some(r.u8());
r.i32();
r.f64();
for _ in 0..64 {
r.i32();
}
r.skip(1);
let alts: Vec<u8> = (0..32).map(|_| r.u8()).collect();
alt0 = alts.first().copied();
for _ in 0..32 {
r.u8();
}
}
r.skip_sub();
if r.err {
return Err("Pads6 stream was not parsed correctly".into());
}
Ok(Pad {
name, layer, net, component, x, y, top, mid, bot, hole, shapes, direction, plated, padmode,
sub5_len: s5, holeshape, inner_x, alt0, mask_modes: (pm, sm), mask_values: (paste, mask),
})
}
pub fn text_props_stream(b: &[u8]) -> Result<Vec<HashMap<String, String>>, String> {
let mut r = R::new(b);
let mut out = vec![];
while r.remaining() >= 4 {
let kv = r.props();
if kv.is_empty() || r.err {
return Err("text record without properties".into());
}
out.push(kv);
}
if r.remaining() != 0 {
return Err("text stream not fully parsed".into());
}
Ok(out)
}
#[derive(Debug)]
pub struct Text {
pub component: u16,
pub x: i32,
pub y: i32,
pub is_comment: bool,
pub is_designator: bool,
pub widx: u32,
pub legacy: String,
}
pub fn text(r: &mut R) -> Result<Text, String> {
if r.u8() != 5 {
return Err("Texts6 stream has invalid recordtype".into());
}
let s1 = r.sub_len();
let _layer = r.u8();
r.skip(6);
let component = r.u16();
r.skip(4);
let x = r.i32();
let y = r.i32();
r.i32();
r.u16();
r.f64();
r.u8();
r.i32();
let (mut is_comment, mut is_designator, mut widx) = (false, false, u32::MAX);
if s1 >= 123 {
is_comment = r.u8() != 0;
is_designator = r.u8() != 0;
r.skip(1);
r.u8();
r.u8();
r.u8();
r.skip(64);
r.u8();
r.i32();
widx = r.u32();
}
r.skip_sub();
r.sub_len();
let legacy = r.wx_string();
r.skip_sub();
if r.err {
return Err("Texts6 stream was not parsed correctly".into());
}
Ok(Text { component, x, y, is_comment, is_designator, widx, legacy })
}
#[derive(Debug)]
pub struct Track {
pub layer: u8,
pub net: u16,
pub component: u16,
pub start: (i32, i32),
pub end: (i32, i32),
pub width: i32,
pub layer_v7: Option<u32>,
}
pub fn track(r: &mut R) -> Result<Track, String> {
if r.u8() != 4 {
return Err("Tracks6 stream has invalid recordtype".into());
}
r.sub_len();
let layer = r.u8();
r.u8();
r.u8();
let net = r.u16();
r.u16();
let component = r.u16();
r.skip(4);
let start = (r.i32(), r.i32());
let end = (r.i32(), r.i32());
let width = r.i32();
r.u16();
r.skip(1);
let rem = r.sub_remaining();
let mut layer_v7 = None;
if rem >= 9 {
r.u32();
r.skip(1);
layer_v7 = Some(r.u32());
}
r.skip_sub();
if r.err {
return Err("Tracks6 stream was not parsed correctly".into());
}
Ok(Track { layer, net, component, start, end, width, layer_v7 })
}
/// AREGION6 with aExtendedVertices = false (how KiCad reads BoardRegions).
pub fn region(r: &mut R) -> Result<(HashMap<String, String>, Vec<(i64, i64)>), String> {
if r.u8() != 11 {
return Err("Regions6 stream has invalid recordtype".into());
}
r.sub_len();
r.u8();
r.u8();
r.u8();
r.u16();
r.u16();
r.u16();
r.skip(5);
let holes = r.u16();
r.skip(2);
let kv = r.props();
let n = r.u32();
let pts = (0..n).map(|_| (r.f64().round() as i64, r.f64().round() as i64)).collect();
for _ in 0..holes {
let m = r.u32();
for _ in 0..m {
r.f64();
r.f64();
}
}
r.skip_sub();
if r.err {
return Err("region not parsed correctly".into());
}
Ok((kv, pts))
}
pub fn widestrings(b: &[u8]) -> HashMap<u32, String> {
let mut r = R::new(b);
let mut t = HashMap::new();
while r.remaining() >= 8 {
let idx = r.u32();
let mut len = r.u32() as usize;
let mut s = String::new();
if len <= 2 {
len = 0;
} else {
if len > r.remaining() {
break;
}
let raw = &r.b[r.pos..r.pos + len - 2];
let u: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
s = String::from_utf16_lossy(&u);
}
r.pos += len;
t.insert(idx, s);
}
t
}
pub fn all<T>(b: &[u8], f: fn(&mut R) -> Result<T, String>) -> Result<Vec<T>, String> {
let mut r = R::new(b);
let mut v = vec![];
while r.remaining() >= 4 {
v.push(f(&mut r)?);
}
if r.remaining() != 0 {
return Err("stream not fully parsed".into());
}
Ok(v)
}
}
fn u(mm: f64) -> i32 {
(mm / (0.0254 / 10000.0)).round() as i32
}
fn header(cf: &mut Cf, storage: &str) -> u32 {
let h = read_stream(cf, &format!("/{storage}/Header"));
assert_eq!(h.len(), 4, "{storage}/Header size");
u32::from_le_bytes(h.try_into().unwrap())
}
/// Record counts for every stream whose layout we know, checked against its Header.
fn check_headers(cf: &mut Cf, label: &str) {
let text = ["Components6", "Board6", "Nets6", "Classes6", "Polygons6", "Models", "UniqueIDPrimitiveInformation"];
for s in text {
let d = read_stream(cf, &format!("/{s}/Data"));
let n = kspec::text_props_stream(&d).unwrap_or_else(|e| panic!("{label} {s}: {e}")).len();
assert_eq!(header(cf, s) as usize, n, "{label} {s} header");
}
let pads = kspec::all(&read_stream(cf, "/Pads6/Data"), kspec::pad).unwrap();
assert_eq!(header(cf, "Pads6") as usize, pads.len(), "{label} Pads6 header");
let texts = kspec::all(&read_stream(cf, "/Texts6/Data"), kspec::text).unwrap();
assert_eq!(header(cf, "Texts6") as usize, texts.len(), "{label} Texts6 header");
let tracks = kspec::all(&read_stream(cf, "/Tracks6/Data"), kspec::track).unwrap();
assert_eq!(header(cf, "Tracks6") as usize, tracks.len(), "{label} Tracks6 header");
let ws = kspec::widestrings(&read_stream(cf, "/WideStrings6/Data"));
assert_eq!(header(cf, "WideStrings6") as usize, ws.len(), "{label} WideStrings6 header");
if cf.is_stream("/PrimitiveGuids/Data") {
let g = read_stream(cf, "/PrimitiveGuids/Data");
assert_eq!(g.len() % 24, 0);
assert_eq!(header(cf, "PrimitiveGuids") as usize, g.len() / 24, "{label} PrimitiveGuids header");
}
}
#[test]
fn read_all_samples() {
for p in samples() {
let name = p.file_stem().unwrap().to_string_lossy().to_string();
let bb = altium::outline_bbox(&p).unwrap();
let b = altium::read(&p).unwrap();
let ob = b.outline_bbox().unwrap();
assert!((ob.xmin - bb.xmin).abs() < 1e-9 && (ob.ymax - bb.ymax).abs() < 1e-9);
let mut cf = open(&p);
assert_eq!(b.components.len() as u32, header(&mut cf, "Components6"), "{name} components");
assert_eq!(b.pad_count() as u32, header(&mut cf, "Pads6"), "{name} pads");
assert_eq!(b.vias.len() as u32, header(&mut cf, "Vias6"), "{name} vias");
let pads: Vec<_> = b.components.iter().flat_map(|c| c.pads.iter()).chain(b.free_pads.iter()).collect();
let inside = pads
.iter()
.filter(|q| q.x >= bb.xmin - 0.5 && q.x <= bb.xmax + 0.5 && q.y >= bb.ymin - 0.5 && q.y <= bb.ymax + 0.5)
.count();
let frac = inside as f64 / pads.len() as f64;
println!(
"{name}: bbox x {:.4}..{:.4} y {:.4}..{:.4} ({:.4} x {:.4} mm) comps {} pads {} ({:.1}% inside) \
tracks top {} bottom {} vias {} silk top {} bottom {} models {} bodies {}",
bb.xmin, bb.xmax, bb.ymin, bb.ymax, bb.w(), bb.h(), b.components.len(), pads.len(), frac * 100.0,
b.tracks_top.len(), b.tracks_bottom.len(), b.vias.len(), b.silk_top.len(), b.silk_bottom.len(),
b.models.len(), b.components.iter().map(|c| c.bodies.len()).sum::<usize>()
);
assert!(frac > 0.95, "{name}: pads outside the outline");
assert!(b.components.iter().all(|c| !c.r#ref.is_empty()), "{name}: empty designator");
for (k, step) in &b.models {
assert!(step.starts_with(b"ISO-10303-21"), "{name}: model {k} is not STEP");
}
}
}
fn check_write(p: &Path, vcut: bool) {
let name = p.file_stem().unwrap().to_string_lossy().to_string();
let bb = altium::outline_bbox(p).unwrap();
let plan = plan::make_plan("altium", &p.to_string_lossy(), bb, vcut);
let out = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("{name}{}.molecule.PcbDoc", if vcut { ".vcut" } else { "" }));
let summary = altium::write_molecule(p, &plan, &out).unwrap();
let rewritten: Vec<String> =
summary["streams_rewritten"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
// Untouched streams byte-identical, same storage tree and CLSIDs.
let mut a = open(p);
let mut b = open(&out);
let ea = all_entries(&a);
let eb = all_entries(&b);
assert_eq!(ea, eb, "{name}: entry tree differs");
let mut same = 0;
for (path, (is_stream, _)) in &ea {
if !is_stream {
continue;
}
let (da, db) = (read_stream(&mut a, path), read_stream(&mut b, path));
if rewritten.contains(path) {
assert_ne!(da, db, "{name}: {path} listed as rewritten but unchanged");
} else {
assert_eq!(da, db, "{name}: {path} changed");
same += 1;
}
}
if !vcut {
assert!(!rewritten.iter().any(|s| s.starts_with("/Tracks6")), "{name}: tracks touched without vcut");
}
check_headers(&mut b, &name);
let strict = |q: &Path| cfb::CompoundFile::open_strict(Cursor::new(std::fs::read(q).unwrap())).is_ok();
if strict(p) {
assert!(strict(&out), "{name}: output fails strict CFB validation");
}
// Independent re-parse of the new records.
let n_comp = kspec::text_props_stream(&read_stream(&mut a, "/Components6/Data")).unwrap().len();
let comps = kspec::text_props_stream(&read_stream(&mut b, "/Components6/Data")).unwrap();
assert_eq!(comps.len(), n_comp + 4);
let n_pads = kspec::all(&read_stream(&mut a, "/Pads6/Data"), kspec::pad).unwrap().len();
let pads = kspec::all(&read_stream(&mut b, "/Pads6/Data"), kspec::pad).unwrap();
assert_eq!(pads.len(), n_pads + 4);
let texts = kspec::all(&read_stream(&mut b, "/Texts6/Data"), kspec::text).unwrap();
let ws = kspec::widestrings(&read_stream(&mut b, "/WideStrings6/Data"));
let s = plan.spec();
for (k, pin) in plan.pins.iter().enumerate() {
let c = &comps[n_comp + k];
assert_eq!(c["LAYER"], "TOP");
assert_eq!(c["PATTERN"], s.footprint);
assert_eq!(c["SOURCEDESIGNATOR"], pin.r#ref);
assert_eq!(kspec::mil(c, "X"), u(pin.x) as i64, "{name} {} X", pin.r#ref);
assert_eq!(kspec::mil(c, "Y"), u(pin.y) as i64);
assert!(!c.contains_key("SOURCEUNIQUEID"));
let q = &pads[n_pads + k];
assert_eq!(q.component as usize, n_comp + k);
assert_eq!((q.layer, q.net, q.name.as_str()), (74, 0xFFFF, "1"));
assert_eq!((q.x, q.y), (u(pin.x), u(pin.y)));
let d = u(s.pad_mm);
assert_eq!((q.top, q.mid, q.bot), ((d, d), (d, d), (d, d)));
assert_eq!(q.hole, u(s.drill_mm));
assert_eq!(q.shapes, (1, 1, 1));
assert!(q.plated && q.padmode == 0 && q.direction == 0.0);
assert_eq!((q.mask_modes, q.mask_values), ((1, 1), (0, 0)), "{name}: mask expansion not rule-based");
assert_eq!(q.sub5_len, pads[0].sub5_len, "{name}: pad record version differs from the board's");
if let Some(hs) = q.holeshape {
assert_eq!(hs, 0);
assert!(q.inner_x.iter().all(|&x| x == d));
assert_eq!(q.alt0, Some(1));
}
let mine: Vec<_> = texts.iter().filter(|t| t.component as usize == n_comp + k).collect();
let des = mine.iter().find(|t| t.is_designator).expect("designator text");
assert_eq!(ws.get(&des.widx).map(|s| s.as_str()), Some(pin.r#ref.as_str()));
assert_eq!(des.legacy, pin.r#ref);
assert!(mine.iter().any(|t| t.is_comment));
}
// UniqueIDPrimitiveInformation follows the pads.
let uid = kspec::text_props_stream(&read_stream(&mut b, "/UniqueIDPrimitiveInformation/Data")).unwrap();
assert_eq!(uid.len(), pads.len());
for (i, e) in uid.iter().enumerate() {
assert_eq!(e["PRIMITIVEINDEX"], i.to_string());
}
// Outline: Board6 vertices and the layer-stack region are the plan rectangle.
let b6 = &kspec::text_props_stream(&read_stream(&mut b, "/Board6/Data")).unwrap()[0];
let nv = (0..).take_while(|i| b6.contains_key(&format!("VX{i}"))).count();
assert!(nv == 4 || nv == 5);
let corners = [(plan.new.xmin, plan.new.ymin), (plan.new.xmax, plan.new.ymin), (plan.new.xmax, plan.new.ymax), (plan.new.xmin, plan.new.ymax)];
for i in 0..nv {
let (x, y) = corners[i % 4];
assert_eq!(kspec::mil(b6, &format!("VX{i}")), u(x) as i64);
assert_eq!(kspec::mil(b6, &format!("VY{i}")), u(y) as i64);
assert_eq!(b6[&format!("KIND{i}")], "0");
}
let regions = kspec::all(&read_stream(&mut b, "/BoardRegions/Data"), kspec::region).unwrap();
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].1, corners.iter().map(|&(x, y)| (u(x) as i64, u(y) as i64)).collect::<Vec<_>>());
// Board6: every non-outline property survives unchanged.
let a6 = &kspec::text_props_stream(&read_stream(&mut a, "/Board6/Data")).unwrap()[0];
let is_vert = |k: &str| {
["KIND", "VX", "VY", "CX", "CY", "SA", "EA", "R"].iter().any(|p| {
k.strip_prefix(p).is_some_and(|r| !r.is_empty() && r.bytes().all(|c| c.is_ascii_digit()))
})
};
let changed: Vec<&String> = a6.keys().filter(|k| !is_vert(k) && b6.get(*k) != a6.get(*k)).collect();
let mut allowed = 0;
for k in &changed {
assert!(vcut && (k.contains("NAME") || k.contains("MECHENABLED") || k.contains("USEDBYPRIMS")), "{name}: Board6 {k} changed");
allowed += 1;
}
// V-cut lines.
if vcut {
let layer = summary["vcut_layer"]["v6_layer"].as_u64().expect("vcut layer") as u8;
let tracks = kspec::all(&read_stream(&mut b, "/Tracks6/Data"), kspec::track).unwrap();
let mine: Vec<_> = tracks.iter().rev().take(4).collect();
let lines = plan.vcut_lines();
for (t, l) in mine.iter().rev().zip(lines.iter()) {
assert_eq!(t.layer, layer);
assert_eq!((t.net, t.component), (0xFFFF, 0xFFFF));
assert_eq!((t.start, t.end), ((u(l[0]), u(l[1])), (u(l[2]), u(l[3]))));
if let Some(v7) = t.layer_v7 {
assert_eq!(v7, 0x0102_0000 + (layer as u32 - 56));
}
}
let orig_tracks = kspec::all(&read_stream(&mut a, "/Tracks6/Data"), kspec::track).unwrap();
assert!(orig_tracks.iter().all(|t| t.layer != layer), "{name}: vcut layer was in use");
}
// Our own reader sees the molecule.
let m = altium::read(&out).unwrap();
let mb = m.outline_bbox().unwrap();
for (got, want) in [(mb.xmin, plan.new.xmin), (mb.ymin, plan.new.ymin), (mb.xmax, plan.new.xmax), (mb.ymax, plan.new.ymax)] {
assert!((got - want).abs() < 2e-6, "{name}: outline {got} vs {want}");
}
let mp: HashMap<&str, &model::Component> = m.components.iter().map(|c| (c.r#ref.as_str(), c)).collect();
for pin in &plan.pins {
let c = mp[pin.r#ref.as_str()];
assert_eq!(c.pads.len(), 1);
let q = &c.pads[0];
assert!((q.x - pin.x).abs() < 2e-6 && (q.y - pin.y).abs() < 2e-6);
assert!((q.w - s.pad_mm).abs() < 2e-6 && (q.drill - s.drill_mm).abs() < 2e-6);
assert_eq!(c.package, s.footprint);
}
println!(
"{name}{}: new {:.4} x {:.4} mm at ({:.4}, {:.4}); {} streams identical, {} rewritten; Board6 layer keys changed {}; warnings {}",
if vcut { " +vcut" } else { "" }, plan.new.w(), plan.new.h(), plan.new.xmin, plan.new.ymin, same, rewritten.len(),
allowed, summary["warnings"]
);
}
#[test]
fn write_all_samples() {
for p in samples() {
check_write(&p, false);
check_write(&p, true);
}
}