main
Colby Knox board-to-molecule 1.0.0: Rust source, six before/after examples (KiCad, Fusion, Altium), Adom 3D viewer readme eb9ea91 4d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
//! PcbDoc molecule writer: copy the source compound file, then rewrite only the
//! streams the molecule touches (Board6 outline, BoardRegions, MP1..MP4 as
//! components + pads + designator/comment texts, optional V-cut tracks) and the
//! Header counts and per-primitive index streams that go with them.

use super::read::*;
use super::rec::*;
use crate::plan::Plan;
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashSet};
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;

const MEDIUM_PCBLIB: &[u8] = include_bytes!("../../../assets/pins/MachinePinMediumShort.PcbLib");
const LARGE_PCBLIB: &[u8] = include_bytes!("../../../assets/pins/MachinePinLargeShort.PcbLib");

/// Altium V7 layer id base for mechanical layers (Mechanical N = base + N).
const V7_MECH_BASE: u32 = 0x0102_0000;
const VCUT_WIDTH_MM: f64 = 0.1;
const VCUT_LAYER_NAME: &str = "V-Cut";

/// Component keys that tie a footprint to a schematic part or a library item.
/// The cloned MP components drop them so they read as plain placed footprints.
const LINK_KEYS: &[&str] = &[
    "SOURCEUNIQUEID",
    "SOURCEHIERARCHICALPATH",
    "SOURCECOMPONENTLIBRARY",
    "SOURCECOMPLIBIDENTIFIERKIND",
    "SOURCECOMPLIBRARYIDENTIFIER",
    "CHANNELOFFSET",
    "ITEMGUID",
    "ITEMREVISIONGUID",
    "REVISIONGUID",
    "VAULTGUID",
    "FOOTPRINTITEMGUID",
    "FOOTPRINTREVISIONGUID",
    "FOOTPRINTVAULTGUID",
    "COMPONENTKIND",
    "COMPONENTKINDVERSION2",
    "FOOTPRINTDESCRIPTION",
    "SOURCEDESCRIPTION",
    "UNICODE",
];

fn is_vertex_key(k: &str) -> bool {
    for p in ["KIND", "VX", "VY", "CX", "CY", "SA", "EA", "R"] {
        if let Some(rest) = k.strip_prefix(p) {
            if !rest.is_empty() && rest.bytes().all(|c| c.is_ascii_digit()) {
                return true;
            }
        }
    }
    false
}

/// Eight random uppercase letters, Altium's UNIQUEID style.
fn unique_id(taken: &mut HashSet<String>) -> String {
    loop {
        let b = uuid::Uuid::new_v4().into_bytes();
        let s: String = b[..8].iter().map(|v| (b'A' + v % 26) as char).collect();
        if taken.insert(s.clone()) {
            return s;
        }
    }
}

struct Ctx {
    warnings: Vec<String>,
    out: BTreeMap<String, Vec<u8>>,
    added: serde_json::Map<String, Value>,
}

impl Ctx {
    /// Rewrite `<storage>/Header` as the real record count plus `added`.
    fn bump(&mut self, cf: &mut Cfb, storage: &str, before: usize, added: usize) {
        match header_count(cf, storage) {
            Some(h) if h as usize != before => self.warnings.push(format!(
                "{storage}/Header said {h} records but the stream holds {before}; wrote {}",
                before + added
            )),
            None => self.warnings.push(format!("{storage}/Header missing or malformed; wrote a fresh count")),
            _ => {}
        }
        self.out.insert(format!("/{storage}/Header"), ((before + added) as u32).to_le_bytes().to_vec());
        self.added.insert(storage.to_string(), json!(added));
    }
}

/// The pad record cloned for each machine pin: a through-hole pad of the source
/// board when it has one (same record version as the rest of the file), else the
/// pad from the bundled machine-pin PcbLib.
fn template_pad(pads: &[BinRec], comp_top: &dyn Fn(u16) -> bool, plan: &Plan) -> Result<(BinRec, Option<u16>, String)> {
    let mut best: Option<(i32, usize)> = None;
    for (i, r) in pads.iter().enumerate() {
        let Some(p) = decode_pad(r) else { continue };
        if p.layer != L_MULTI || p.hole <= 0.0 || !p.plated || p.slot || p.component == NONE16 {
            continue;
        }
        let score = (comp_top(p.component) as i32) * 4
            + (r.subs[5].is_empty() as i32) * 2
            + (p.shape == 1 && (p.w - p.h).abs() < 1e-9) as i32
            + (r.subs[4].get(101..103) == Some(&[1, 1][..])) as i32;
        if best.is_none_or(|(s, _)| score > s) {
            best = Some((score, i));
        }
    }
    if let Some((_, i)) = best {
        let comp = decode_pad(&pads[i]).map(|p| p.component);
        return Ok((pads[i].clone(), comp, format!("source Pads6 record {i}")));
    }
    let lib = if plan.spec().footprint.contains("Large") { LARGE_PCBLIB } else { MEDIUM_PCBLIB };
    let mut cf = open_bytes(lib.to_vec())?;
    let data = stream(&mut cf, &format!("/{}/Data", plan.spec().footprint)).context("bundled PcbLib footprint stream")?;
    let n = rd_u32(&data, 0).context("bundled PcbLib header")? as usize;
    let recs = split_bin(&data[4 + n..], 6).ok();
    let pad = recs.and_then(|r| r.into_iter().next()).filter(|r| decode_pad(r).is_some());
    let pad = pad.context("bundled PcbLib has no pad record")?;
    Ok((pad, None, format!("bundled {}.PcbLib pad", plan.spec().footprint)))
}

fn make_pad(tpl: &BinRec, comp: u16, x: f64, y: f64, dia: f64, drill: f64) -> BinRec {
    let mut r = tpl.clone();
    r.subs[0] = make_pascal("1");
    let (d, h) = (mm2u(dia), mm2u(drill));
    let s5 = &mut r.subs[4];
    s5[0] = L_MULTI;
    wr_u16(s5, 3, NONE16); // net
    wr_u16(s5, 5, NONE16); // polygon
    wr_u16(s5, 7, comp);
    wr_i32(s5, 13, mm2u(x));
    wr_i32(s5, 17, mm2u(y));
    for off in [21, 25, 29, 33, 37, 41] {
        wr_i32(s5, off, d);
    }
    wr_i32(s5, 45, h);
    s5[49] = 1;
    s5[50] = 1;
    s5[51] = 1;
    wr_f64(s5, 52, 0.0);
    s5[60] = 1; // plated
    s5[62] = 0; // simple pad stack
    s5[1] &= !0xE0; // not tented, not a fab test point
    s5[2] &= !0x01;
    wr_i32(s5, 86, 0); // paste and solder mask expansions follow the board rules
    wr_i32(s5, 90, 0);
    s5[101] = 1;
    s5[102] = 1;
    if s5.len() >= 114 {
        wr_f64(s5, 106, 0.0); // hole rotation
    } else if s5.len() >= 110 {
        wr_u32(s5, 106, 0);
    }
    let s6 = &mut r.subs[5];
    if s6.len() >= 596 {
        for k in 0..29 {
            wr_i32(s6, k * 4, d);
            wr_i32(s6, 116 + k * 4, d);
            s6[232 + k] = 1;
        }
        s6[262] = 0; // round hole
        wr_i32(s6, 263, 0);
        wr_f64(s6, 267, 0.0);
        s6[275..531].fill(0);
        s6[532..564].fill(1);
        s6[564..596].fill(0);
    }
    r
}

/// First mechanical layer (1..16) that no primitive uses, has no layer kind, and
/// still carries its default name. Mechanical 1 when it qualifies.
fn free_mech_layer(cf: &mut Cfb, board: &RawProps) -> Option<u8> {
    let mut used = HashSet::new();
    for (storage, nsub) in [
        ("Tracks6", 1),
        ("Arcs6", 1),
        ("Fills6", 1),
        ("Texts6", 2),
        ("Regions6", 1),
        ("ShapeBasedRegions6", 1),
        ("ComponentBodies6", 1),
        ("ShapeBasedComponentBodies6", 1),
        ("Pads6", 6),
        ("Vias6", 1),
    ] {
        let Some(d) = stream(cf, &format!("/{storage}/Data")) else { continue };
        let Ok(recs) = split_bin(&d, nsub) else { return None };
        for r in recs {
            let s = if storage == "Pads6" { &r.subs[4] } else { &r.subs[0] };
            if let Some(&l) = s.first() {
                used.insert(l);
            }
        }
    }
    for storage in ["Polygons6", "Dimensions6"] {
        let Some(d) = stream(cf, &format!("/{storage}/Data")) else { continue };
        let skip = if storage == "Dimensions6" { 2 } else { 0 };
        let mut i = 0;
        while i + skip + 4 <= d.len() {
            let Some(n) = rd_u32(&d, i + skip) else { break };
            let end = (i + skip + 4 + n as usize).min(d.len());
            let p = Props::parse(&d[i + skip + 4..end]);
            for k in ["LAYER", "V7_LAYER"] {
                if let Some(m) = p.get(k).and_then(|v| v.strip_prefix("MECHANICAL")).and_then(|v| v.parse::<u8>().ok()) {
                    if (1..=16).contains(&m) {
                        used.insert(56 + m);
                    }
                }
            }
            i = end;
        }
    }
    (1..=16u8).find(|&n| {
        let l = 56 + n;
        !used.contains(&l)
            && board.get(&format!("LAYER{l}MECHKIND")).is_none()
            && board.get(&format!("LAYER{l}NAME")).is_none_or(|v| v == format!("Mechanical {n}"))
    })
}

fn enable_mech_layer(board: &mut RawProps, n: u8) {
    let l = 56 + n;
    board.set(&format!("LAYER{l}NAME"), VCUT_LAYER_NAME);
    board.set(&format!("LAYER{l}MECHENABLED"), "TRUE");
    let id = (V7_MECH_BASE + n as u32).to_string();
    let cache: Vec<String> = board
        .keys()
        .into_iter()
        .filter(|k| k.starts_with("V9_CACHE_LAYER") && k.ends_with("_LAYERID"))
        .filter(|k| board.get(k).as_deref() == Some(id.as_str()))
        .collect();
    for k in cache {
        let pre = k.trim_end_matches("LAYERID");
        board.set(&format!("{pre}NAME"), VCUT_LAYER_NAME);
        board.set(&format!("{pre}MECHENABLED"), "TRUE");
        if board.get(&format!("{pre}USEDBYPRIMS")).is_some() {
            board.set(&format!("{pre}USEDBYPRIMS"), "TRUE");
        }
    }
}

/// Rectangle corners counterclockwise from the front-left corner (mm).
fn rect_corners(plan: &Plan) -> [(f64, f64); 4] {
    let n = &plan.new;
    [(n.xmin, n.ymin), (n.xmax, n.ymin), (n.xmax, n.ymax), (n.xmin, n.ymax)]
}

fn rewrite_board6(raw: &mut RawProps, plan: &Plan, closed: bool) -> usize {
    let at = raw.remove_where(is_vertex_key).unwrap_or(raw.toks.len());
    let mut pts: Vec<(f64, f64)> = rect_corners(plan).to_vec();
    if closed {
        pts.push(pts[0]);
    }
    let zero = altium_float(0.0);
    let mut toks = vec![];
    for (i, (x, y)) in pts.iter().enumerate() {
        for t in [
            format!("KIND{i}=0"),
            format!("VX{i}={}", mm_to_mil(*x)),
            format!("VY{i}={}", mm_to_mil(*y)),
            format!("CX{i}=0mil"),
            format!("CY{i}=0mil"),
            format!("SA{i}={zero}"),
            format!("EA{i}={zero}"),
            format!("R{i}=0mil"),
        ] {
            toks.push(latin1(&t));
        }
    }
    let n = pts.len();
    raw.toks.splice(at..at, toks);
    n
}

/// Replace the outline of the single layer-stack region (a polygonized copy of
/// the board shape Altium keeps in BoardRegions).
fn rewrite_board_regions(data: &[u8], plan: &Plan) -> Result<Vec<u8>> {
    let mut recs = split_bin(data, 1)?;
    if recs.len() != 1 || recs[0].kind != 11 {
        bail!("{} board regions (rigid-flex or unknown layout)", recs.len());
    }
    let s = &recs[0].subs[0];
    let plen = rd_u32(s, 18).context("region header")? as usize;
    let at = 22 + plen;
    let nv = rd_u32(s, at).context("region vertex count")? as usize;
    let end = at + 4 + nv * 16;
    if end > s.len() {
        bail!("region vertex list overruns its record");
    }
    let mut ns = s[..at].to_vec();
    let corners = rect_corners(plan);
    ns.extend_from_slice(&(corners.len() as u32).to_le_bytes());
    for (x, y) in corners {
        ns.extend_from_slice(&((x / MM_PER_UNIT).round()).to_le_bytes());
        ns.extend_from_slice(&((y / MM_PER_UNIT).round()).to_le_bytes());
    }
    ns.extend_from_slice(&s[end..]);
    recs[0].subs[0] = ns;
    let mut out = vec![];
    recs[0].encode(&mut out);
    Ok(out)
}

/// PrimitiveGuids (newer files): 24-byte entries of `u32 kind word`, `u32 index`,
/// 16-byte GUID. The kind word's low byte is the primitive record type. Returns the
/// word used for `rtype` when its entries cover exactly indices 0..count.
fn guid_word(g: &[u8], rtype: u8, count: usize) -> Option<u32> {
    let mut by: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
    for e in g.chunks_exact(24) {
        let w = rd_u32(e, 0)?;
        if w & 0xFF == rtype as u32 {
            by.entry(w).or_default().push(rd_u32(e, 4)?);
        }
    }
    by.into_iter().find_map(|(w, mut v)| {
        v.sort_unstable();
        (v.len() == count && v.iter().enumerate().all(|(i, &x)| x as usize == i)).then_some(w)
    })
}

pub fn write_molecule(src: &Path, plan: &Plan, out_path: &Path) -> Result<Value> {
    let bytes = std::fs::read(src).with_context(|| format!("reading {}", src.display()))?;
    let mut cf = open_bytes(bytes)?;
    let mut ctx = Ctx { warnings: vec![], out: BTreeMap::new(), added: serde_json::Map::new() };
    let spec = plan.spec();

    // Board6: new outline, optional V-cut layer.
    let b6 = stream(&mut cf, "/Board6/Data").context("PcbDoc has no Board6/Data stream")?;
    let b6recs = split_text(&b6).context("Board6/Data")?;
    if b6recs.len() != 1 {
        ctx.warnings.push(format!("Board6/Data has {} records; editing the first", b6recs.len()));
    }
    let mut board = RawProps::parse(b6recs[0]);
    let old = board_vertices(&Props::parse(b6recs[0]));
    let closed = old.len() > 1 && {
        let (a, z) = (old[0], old[old.len() - 1]);
        (a.x - z.x).abs() < 1e-6 && (a.y - z.y).abs() < 1e-6
    };
    let nverts = rewrite_board6(&mut board, plan, closed || old.is_empty());

    // Components, pads, texts.
    let cdata = stream(&mut cf, "/Components6/Data").context("no Components6/Data")?;
    let crecs = split_text(&cdata).context("Components6/Data")?;
    let comps: Vec<Props> = crecs.iter().map(|r| Props::parse(r)).collect();
    let is_top = |i: u16| comps.get(i as usize).is_some_and(|p| !p.str("LAYER").eq_ignore_ascii_case("BOTTOM"));
    let pdata = stream(&mut cf, "/Pads6/Data").unwrap_or_default();
    let pads = split_bin(&pdata, 6).context("Pads6/Data")?;
    let tdata = stream(&mut cf, "/Texts6/Data").unwrap_or_default();
    let texts = split_bin(&tdata, 2).context("Texts6/Data")?;
    let tinfo: Vec<Option<TextRec>> = texts.iter().map(decode_text).collect();
    let texts_of = |ci: u16| {
        let d = tinfo.iter().position(|t| t.as_ref().is_some_and(|t| t.component == ci && t.is_designator));
        let c = tinfo.iter().position(|t| t.as_ref().is_some_and(|t| t.component == ci && t.is_comment));
        (d, c)
    };

    let (tpl_pad, pad_comp, pad_src) = template_pad(&pads, &is_top, plan)?;
    let with_texts = |ci: u16| {
        let (d, c) = texts_of(ci);
        d.is_some() && c.is_some()
    };
    let tpl_comp = pad_comp
        .filter(|&c| is_top(c) && with_texts(c))
        .or_else(|| (0..comps.len() as u16).find(|&c| is_top(c) && with_texts(c)))
        .or_else(|| (0..comps.len() as u16).find(|&c| with_texts(c)))
        .or_else(|| (!comps.is_empty()).then_some(0))
        .context("source board has no components to model the machine pins on")?;
    let (tpl_des, tpl_com) = texts_of(tpl_comp);
    if tpl_des.is_none() || tpl_com.is_none() {
        ctx.warnings.push("no designator/comment text found to clone; MP components have no text primitives".into());
    }

    let mut taken: HashSet<String> = comps.iter().map(|p| p.str("UNIQUEID")).collect();
    let n_comp = comps.len();
    let n_pads = pads.len();
    let n_texts = texts.len();
    let mut new_c = cdata.clone();
    let mut new_p = pdata.clone();
    let mut new_t = tdata.clone();
    let wdata = stream(&mut cf, "/WideStrings6/Data").unwrap_or_default();
    let wide = parse_widestrings(&wdata);
    let mut next_w = wide.iter().map(|(i, _)| *i + 1).max().unwrap_or(0);
    let mut new_w = wdata.clone();
    let mut n_wide_added = 0;
    let mut texts_added = 0;
    let mut pin_report = vec![];

    for (k, pin) in plan.pins.iter().enumerate() {
        let ci = (n_comp + k) as u16;
        // Component record.
        let mut c = RawProps::parse(crecs[tpl_comp as usize]);
        for key in LINK_KEYS {
            c.remove(key);
        }
        c.remove_where(|k| k.starts_with("UNICODE__") || k.starts_with("%UTF8%"));
        let uid = unique_id(&mut taken);
        for (key, val) in [
            ("SELECTION", "FALSE".to_string()),
            ("LAYER", "TOP".into()),
            ("LOCKED", "FALSE".into()),
            ("X", mm_to_mil(pin.x)),
            ("Y", mm_to_mil(pin.y)),
            ("PATTERN", spec.footprint.into()),
            ("NAMEON", "FALSE".into()),
            ("COMMENTON", "FALSE".into()),
            ("GROUPNUM", "0".into()),
            ("COUNT", "0".into()),
            ("ROTATION", altium_float(0.0)),
            ("HEIGHT", "0mil".into()),
            ("UNIONINDEX", "0".into()),
            ("SOURCEDESIGNATOR", pin.r#ref.clone()),
            ("SOURCEFOOTPRINTLIBRARY", format!("{}.PcbLib", spec.footprint)),
            ("SOURCELIBREFERENCE", spec.footprint.into()),
            ("UNIQUEID", uid.clone()),
        ] {
            c.set(key, &val);
        }
        push_text(&mut new_c, &c.encode());

        // Pad record.
        let pad = make_pad(&tpl_pad, ci, pin.x, pin.y, spec.pad_mm, spec.drill_mm);
        pad.encode(&mut new_p);

        // Designator and comment texts.
        for (ti, s) in [(tpl_des, pin.r#ref.as_str()), (tpl_com, spec.footprint)] {
            let Some(ti) = ti else { continue };
            let mut t = texts[ti].clone();
            let a = &mut t.subs[0];
            wr_u16(a, 7, ci);
            wr_i32(a, 13, mm2u(pin.x));
            wr_i32(a, 17, mm2u(pin.y));
            if a.len() >= 123 {
                wr_u32(a, 115, next_w);
                push_widestring(&mut new_w, next_w, s);
                next_w += 1;
                n_wide_added += 1;
            }
            t.subs[1] = make_pascal(s);
            t.encode(&mut new_t);
            texts_added += 1;
        }
        pin_report.push(json!({"ref": pin.r#ref, "component_index": ci, "pad_index": n_pads + k,
                               "x_mm": pin.x, "y_mm": pin.y, "uniqueid": uid}));
    }
    ctx.out.insert("/Components6/Data".into(), new_c);
    ctx.bump(&mut cf, "Components6", n_comp, plan.pins.len());
    ctx.out.insert("/Pads6/Data".into(), new_p);
    ctx.bump(&mut cf, "Pads6", n_pads, plan.pins.len());
    if texts_added > 0 {
        ctx.out.insert("/Texts6/Data".into(), new_t);
        ctx.bump(&mut cf, "Texts6", n_texts, texts_added);
    }
    if n_wide_added > 0 {
        ctx.out.insert("/WideStrings6/Data".into(), new_w);
        ctx.bump(&mut cf, "WideStrings6", wide.len(), n_wide_added);
    }

    // UniqueIDPrimitiveInformation: one entry per pad, indexed by pad order.
    if let Some(u) = stream(&mut cf, "/UniqueIDPrimitiveInformation/Data") {
        match split_text(&u) {
            Ok(urecs) if urecs.len() == n_pads => {
                for r in &urecs {
                    taken.insert(Props::parse(r).str("UNIQUEID"));
                }
                let mut nu = u.clone();
                for k in 0..plan.pins.len() {
                    let id = unique_id(&mut taken);
                    let s = format!("|PRIMITIVEINDEX={}|PRIMITIVEOBJECTID=Pad|UNIQUEID={id}\0", n_pads + k);
                    push_text(&mut nu, s.as_bytes());
                }
                ctx.out.insert("/UniqueIDPrimitiveInformation/Data".into(), nu);
                ctx.bump(&mut cf, "UniqueIDPrimitiveInformation", n_pads, plan.pins.len());
            }
            Ok(urecs) => ctx.warnings.push(format!(
                "UniqueIDPrimitiveInformation has {} entries for {n_pads} pads; left unchanged",
                urecs.len()
            )),
            Err(e) => ctx.warnings.push(format!("UniqueIDPrimitiveInformation unreadable ({e}); left unchanged")),
        }
    }

    // Optional V-cut score lines on a free mechanical layer.
    let mut tracks_added = 0;
    let mut vcut_layer = Value::Null;
    if plan.vcut {
        let tr = stream(&mut cf, "/Tracks6/Data").unwrap_or_default();
        let trecs = split_bin(&tr, 1).context("Tracks6/Data")?;
        let tpl = trecs
            .iter()
            .filter(|r| r.kind == 4 && r.subs[0].len() >= 36)
            .max_by_key(|r| {
                let s = &r.subs[0];
                let free = rd_u16(s, 5) == Some(NONE16) && rd_u16(s, 7) == Some(NONE16) && s[2] != 2;
                (free as u8) * 2 + ((57..=72).contains(&s[0]) as u8)
            })
            .cloned();
        match (tpl, free_mech_layer(&mut cf, &board)) {
            (Some(tpl), Some(n)) => {
                enable_mech_layer(&mut board, n);
                let mut nt = tr.clone();
                for [x1, y1, x2, y2] in plan.vcut_lines() {
                    let mut r = tpl.clone();
                    let s = &mut r.subs[0];
                    s[0] = 56 + n;
                    s[2] = 0;
                    wr_u16(s, 3, NONE16);
                    wr_u16(s, 5, NONE16);
                    wr_u16(s, 7, NONE16);
                    wr_i32(s, 13, mm2u(x1));
                    wr_i32(s, 17, mm2u(y1));
                    wr_i32(s, 21, mm2u(x2));
                    wr_i32(s, 25, mm2u(y2));
                    wr_i32(s, 29, mm2u(VCUT_WIDTH_MM));
                    if s.len() >= 45 {
                        wr_u32(s, 36, 0); // union index
                        wr_u32(s, 41, V7_MECH_BASE + n as u32);
                    }
                    r.encode(&mut nt);
                    tracks_added += 1;
                }
                ctx.out.insert("/Tracks6/Data".into(), nt);
                ctx.bump(&mut cf, "Tracks6", trecs.len(), tracks_added);
                vcut_layer = json!({"mechanical": n, "v6_layer": 56 + n, "name": VCUT_LAYER_NAME});
            }
            (None, _) => ctx.warnings.push("vcut requested but the board has no track record to clone; skipped".into()),
            (_, None) => ctx.warnings.push("vcut requested but no free mechanical layer; skipped".into()),
        }
    }

    // PrimitiveGuids (newer Altium): add GUIDs for pads, texts, tracks we created.
    if let Some(g) = stream(&mut cf, "/PrimitiveGuids/Data") {
        if g.len() % 24 == 0 {
            let mut ng = g.clone();
            let mut n_added = 0;
            for (rtype, base, count) in
                [(2u8, n_pads, plan.pins.len()), (5, n_texts, texts_added), (4, ng_tracks(&mut cf, &ctx), tracks_added)]
            {
                if count == 0 {
                    continue;
                }
                match guid_word(&g, rtype, base) {
                    Some(w) => {
                        for k in 0..count {
                            ng.extend_from_slice(&w.to_le_bytes());
                            ng.extend_from_slice(&((base + k) as u32).to_le_bytes());
                            ng.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
                            n_added += 1;
                        }
                    }
                    None => ctx.warnings.push(format!("PrimitiveGuids: no index for record type {rtype}; no GUIDs added")),
                }
            }
            ctx.warnings.push("PrimitiveGuids: no GUIDs added for the MP components (component entry type unknown)".into());
            if n_added > 0 {
                let before = g.len() / 24;
                ctx.out.insert("/PrimitiveGuids/Data".into(), ng);
                ctx.bump(&mut cf, "PrimitiveGuids", before, n_added);
            }
        } else {
            ctx.warnings.push("PrimitiveGuids has an unexpected size; left unchanged".into());
        }
    }

    // Board6 (outline + layer edits) and BoardRegions.
    let mut nb = vec![];
    push_text(&mut nb, &board.encode());
    for extra in &b6recs[1..] {
        push_text(&mut nb, extra);
    }
    ctx.out.insert("/Board6/Data".into(), nb);
    if let Some(br) = stream(&mut cf, "/BoardRegions/Data") {
        match rewrite_board_regions(&br, plan) {
            Ok(d) => {
                ctx.out.insert("/BoardRegions/Data".into(), d);
            }
            Err(e) => ctx.warnings.push(format!("BoardRegions left unchanged: {e}")),
        }
    }

    // Apply.
    for (path, data) in &ctx.out {
        let mut s = cf.open_stream(path).with_context(|| format!("opening {path}"))?;
        s.seek(SeekFrom::Start(0))?;
        s.write_all(data)?;
        s.set_len(data.len() as u64)?;
        s.flush()?;
    }
    cf.flush()?;
    let bytes = cf.into_inner().into_inner();
    std::fs::write(out_path, &bytes).with_context(|| format!("writing {}", out_path.display()))?;

    Ok(json!({
        "output": out_path.display().to_string(),
        "outline_vertices": nverts,
        "pad_template": pad_src,
        "component_template_index": tpl_comp,
        "pins": pin_report,
        "records_added": Value::Object(ctx.added),
        "streams_rewritten": ctx.out.keys().cloned().collect::<Vec<_>>(),
        "vcut_layer": vcut_layer,
        "warnings": ctx.warnings,
    }))
}

/// Track count before our additions (the base index for new track GUIDs).
fn ng_tracks(cf: &mut Cfb, ctx: &Ctx) -> usize {
    let added = ctx.added.get("Tracks6").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
    ctx.out
        .get("/Tracks6/Data")
        .and_then(|d| split_bin(d, 1).ok())
        .map(|r| r.len() - added)
        .or_else(|| stream(cf, "/Tracks6/Data").and_then(|d| split_bin(&d, 1).ok()).map(|r| r.len()))
        .unwrap_or(0)
}