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