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
//! Low-level Altium PcbDoc record codecs.
//!
//! Text streams are a run of `u32 length` + `|KEY=VALUE|...\0` records. Binary
//! streams (Pads6, Tracks6, Arcs6, Vias6, Texts6, regions, bodies) are a run of
//! `u8 type` + N subrecords, each `u32 length` + bytes. Coordinates are i32 in
//! Altium internal units (1/10000 mil), Y up.

use anyhow::{bail, Result};

/// Millimeters per Altium internal unit (0.0001 mil).
pub const MM_PER_UNIT: f64 = 0.0254 / 10000.0;

pub fn u2mm(v: i32) -> f64 {
    v as f64 * MM_PER_UNIT
}

pub fn mm2u(mm: f64) -> i32 {
    (mm / MM_PER_UNIT).round() as i32
}

/// "123.45mil" (optionally with a leading '+' or spaces) to mm.
pub fn mil_to_mm(s: &str) -> Option<f64> {
    let t = s.trim();
    let t = t.strip_suffix("mil")?.trim().trim_start_matches('+');
    t.parse::<f64>().ok().map(|m| m * 0.0254)
}

/// mm to Altium's "1234.5678mil" text form (0.0001 mil resolution).
pub fn mm_to_mil(mm: f64) -> String {
    let units = (mm / MM_PER_UNIT).round() as i64;
    let neg = units < 0;
    let a = units.unsigned_abs();
    let (int, frac) = (a / 10000, a % 10000);
    let mut s = if frac == 0 {
        format!("{int}")
    } else {
        let f = format!("{frac:04}");
        format!("{int}.{}", f.trim_end_matches('0'))
    };
    if neg && units != 0 {
        s.insert(0, '-');
    }
    s.push_str("mil");
    s
}

/// Altium's float text form for angles, e.g. " 9.00000000000000E+0001".
pub fn altium_float(v: f64) -> String {
    if v == 0.0 {
        return " 0.00000000000000E+0000".into();
    }
    let s = format!("{:.14E}", v.abs());
    let (mant, exp) = s.split_once('E').unwrap_or((&s, "0"));
    let e: i32 = exp.parse().unwrap_or(0);
    let sign = if v < 0.0 { '-' } else { ' ' };
    let esign = if e < 0 { '-' } else { '+' };
    format!("{sign}{mant}E{esign}{:04}", e.abs())
}

pub fn rd_u16(b: &[u8], off: usize) -> Option<u16> {
    Some(u16::from_le_bytes(b.get(off..off + 2)?.try_into().ok()?))
}

pub fn rd_u32(b: &[u8], off: usize) -> Option<u32> {
    Some(u32::from_le_bytes(b.get(off..off + 4)?.try_into().ok()?))
}

pub fn rd_i32(b: &[u8], off: usize) -> Option<i32> {
    Some(i32::from_le_bytes(b.get(off..off + 4)?.try_into().ok()?))
}

pub fn rd_f64(b: &[u8], off: usize) -> Option<f64> {
    Some(f64::from_le_bytes(b.get(off..off + 8)?.try_into().ok()?))
}

pub fn wr_u16(b: &mut [u8], off: usize, v: u16) {
    b[off..off + 2].copy_from_slice(&v.to_le_bytes());
}

pub fn wr_u32(b: &mut [u8], off: usize, v: u32) {
    b[off..off + 4].copy_from_slice(&v.to_le_bytes());
}

pub fn wr_i32(b: &mut [u8], off: usize, v: i32) {
    b[off..off + 4].copy_from_slice(&v.to_le_bytes());
}

pub fn wr_f64(b: &mut [u8], off: usize, v: f64) {
    b[off..off + 8].copy_from_slice(&v.to_le_bytes());
}

/// Split a text-record stream into record payloads (without length prefixes).
pub fn split_text(data: &[u8]) -> Result<Vec<&[u8]>> {
    let mut out = vec![];
    let mut i = 0;
    while i < data.len() {
        let Some(n) = rd_u32(data, i) else { bail!("truncated record length at {i}") };
        let n = n as usize;
        let end = i + 4 + n;
        if end > data.len() {
            bail!("record at {i} overruns the stream ({n} bytes)");
        }
        out.push(&data[i + 4..end]);
        i = end;
    }
    Ok(out)
}

pub fn push_text(out: &mut Vec<u8>, payload: &[u8]) {
    out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
    out.extend_from_slice(payload);
}

/// A binary record: type byte plus its subrecords.
#[derive(Debug, Clone)]
pub struct BinRec {
    pub kind: u8,
    pub subs: Vec<Vec<u8>>,
}

impl BinRec {
    pub fn encode(&self, out: &mut Vec<u8>) {
        out.push(self.kind);
        for s in &self.subs {
            out.extend_from_slice(&(s.len() as u32).to_le_bytes());
            out.extend_from_slice(s);
        }
    }
}

/// Split a binary-record stream where each record has `nsub` subrecords.
pub fn split_bin(data: &[u8], nsub: usize) -> Result<Vec<BinRec>> {
    let mut out = vec![];
    let mut i = 0;
    while i < data.len() {
        let kind = data[i];
        i += 1;
        let mut subs = Vec::with_capacity(nsub);
        for _ in 0..nsub {
            let Some(n) = rd_u32(data, i) else { bail!("truncated subrecord length at {i}") };
            let end = i + 4 + n as usize;
            if end > data.len() {
                bail!("subrecord at {i} overruns the stream");
            }
            subs.push(data[i + 4..end].to_vec());
            i = end;
        }
        out.push(BinRec { kind, subs });
    }
    Ok(out)
}

/// Parsed `|KEY=VALUE|` properties: keys upper-cased, values Latin-1 decoded,
/// in file order.
#[derive(Debug, Clone, Default)]
pub struct Props(pub Vec<(String, String)>);

impl Props {
    pub fn parse(payload: &[u8]) -> Props {
        let end = payload.iter().position(|&c| c == 0).unwrap_or(payload.len());
        let s: String = payload[..end].iter().map(|&c| c as char).collect();
        let mut v = vec![];
        for tok in s.split('|') {
            if let Some((k, val)) = tok.split_once('=') {
                v.push((k.trim().to_ascii_uppercase(), val.trim_end().to_string()));
            }
        }
        Props(v)
    }

    pub fn get(&self, key: &str) -> Option<&str> {
        let utf = format!("%UTF8%{key}");
        self.0
            .iter()
            .find(|(k, _)| *k == utf)
            .or_else(|| self.0.iter().find(|(k, _)| k == key))
            .map(|(_, v)| v.as_str())
    }

    pub fn str(&self, key: &str) -> String {
        self.get(key).unwrap_or("").to_string()
    }

    pub fn f64(&self, key: &str) -> Option<f64> {
        self.get(key)?.trim().parse().ok()
    }

    pub fn mm(&self, key: &str) -> Option<f64> {
        mil_to_mm(self.get(key)?)
    }

    pub fn bool(&self, key: &str) -> Option<bool> {
        self.get(key).map(|v| v == "T" || v == "TRUE")
    }
}

/// Raw `|`-separated tokens of a text record, kept byte-exact so a record can be
/// re-emitted with only the targeted keys changed.
#[derive(Debug, Clone)]
pub struct RawProps {
    pub toks: Vec<Vec<u8>>,
    pub nul: bool,
}

impl RawProps {
    pub fn parse(payload: &[u8]) -> RawProps {
        let nul = payload.last() == Some(&0);
        let body = if nul { &payload[..payload.len() - 1] } else { payload };
        RawProps { toks: body.split(|&c| c == b'|').map(|t| t.to_vec()).collect(), nul }
    }

    pub fn encode(&self) -> Vec<u8> {
        let mut out = self.toks.join(&b'|');
        if self.nul {
            out.push(0);
        }
        out
    }

    fn key_of(tok: &[u8]) -> Option<String> {
        let eq = tok.iter().position(|&c| c == b'=')?;
        Some(String::from_utf8_lossy(&tok[..eq]).trim().to_ascii_uppercase())
    }

    pub fn index_of(&self, key: &str) -> Option<usize> {
        self.toks.iter().position(|t| Self::key_of(t).as_deref() == Some(key))
    }

    pub fn get(&self, key: &str) -> Option<String> {
        let i = self.index_of(key)?;
        let t = &self.toks[i];
        let eq = t.iter().position(|&c| c == b'=')?;
        Some(t[eq + 1..].iter().map(|&c| c as char).collect())
    }

    /// Replace the value of `key` in place, or append `KEY=value` when absent.
    pub fn set(&mut self, key: &str, value: &str) {
        let tok = latin1(&format!("{key}={value}"));
        match self.index_of(key) {
            Some(i) => self.toks[i] = tok,
            None => self.toks.push(tok),
        }
    }

    pub fn remove(&mut self, key: &str) {
        self.toks.retain(|t| Self::key_of(t).as_deref() != Some(key));
    }

    /// Remove every token whose key satisfies `pred`; returns the index of the
    /// first removed token.
    pub fn remove_where(&mut self, pred: impl Fn(&str) -> bool) -> Option<usize> {
        let first = self.toks.iter().position(|t| Self::key_of(t).is_some_and(|k| pred(&k)));
        self.toks.retain(|t| !Self::key_of(t).is_some_and(|k| pred(&k)));
        first
    }

    pub fn keys(&self) -> Vec<String> {
        self.toks.iter().filter_map(|t| Self::key_of(t)).collect()
    }
}

pub fn latin1(s: &str) -> Vec<u8> {
    s.chars().map(|c| if (c as u32) < 256 { c as u8 } else { b'?' }).collect()
}

/// WideStrings6: `u32 index`, `u32 byte length`, UTF-16LE text with a NUL. Lengths
/// of 2 or less carry no bytes at all (KiCad's reading of the format).
pub fn parse_widestrings(data: &[u8]) -> Vec<(u32, String)> {
    let mut out = vec![];
    let mut i = 0;
    while i + 8 <= data.len() {
        let idx = rd_u32(data, i).unwrap_or(0);
        let len = rd_u32(data, i + 4).unwrap_or(0) as usize;
        i += 8;
        if len <= 2 {
            out.push((idx, String::new()));
            continue;
        }
        if i + len > data.len() {
            break;
        }
        let units: Vec<u16> =
            data[i..i + len - 2].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
        out.push((idx, String::from_utf16_lossy(&units)));
        i += len;
    }
    out
}

pub fn push_widestring(out: &mut Vec<u8>, idx: u32, s: &str) {
    let mut b: Vec<u8> = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
    b.extend_from_slice(&[0, 0]);
    out.extend_from_slice(&idx.to_le_bytes());
    out.extend_from_slice(&(b.len() as u32).to_le_bytes());
    out.extend_from_slice(&b);
}

/// Length-prefixed (u8) Latin-1 string.
pub fn pascal(b: &[u8]) -> String {
    let n = b.first().copied().unwrap_or(0) as usize;
    b.get(1..1 + n).unwrap_or(&[]).iter().map(|&c| c as char).collect()
}

pub fn make_pascal(s: &str) -> Vec<u8> {
    let mut b = latin1(s);
    b.truncate(255);
    let mut out = vec![b.len() as u8];
    out.extend(b);
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mil_text_round_trip() {
        assert_eq!(mm_to_mil(25.4), "1000mil");
        assert_eq!(mm_to_mil(0.0), "0mil");
        assert_eq!(mm_to_mil(-1.27), "-50mil");
        assert_eq!(mm_to_mil(57.3065), "2256.1614mil");
        assert!((mil_to_mm("2256.163mil").unwrap() - 57.306540).abs() < 1e-6);
        assert!((mil_to_mm("+5mil").unwrap() - 0.127).abs() < 1e-12);
    }

    #[test]
    fn float_text() {
        assert_eq!(altium_float(0.0), " 0.00000000000000E+0000");
        assert_eq!(altium_float(270.0), " 2.70000000000000E+0002");
        assert_eq!(altium_float(-90.0), "-9.00000000000000E+0001");
    }

    #[test]
    fn raw_props_edit() {
        let mut r = RawProps::parse(b"|A=1|VX0=2mil|B=x\0");
        r.set("B", "y");
        r.remove("A");
        assert_eq!(r.encode(), b"|VX0=2mil|B=y\0".to_vec());
    }
}