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