master
Ray Publish 1.3.3 bce18bc 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
//! `step2glb anchor-glb`: put an existing detailed GLB into the molecule frame WITHOUT
//! touching its meshes, materials, textures or binary payload.
//!
//! The molecule pipeline (`convert --molecule`) starts from STEP, and STEP cannot carry the
//! glTF textures a tscircuit board build already has (traces, silkscreen). For a board whose
//! detailed GLB exists, the only thing missing is the frame: molecule imports are metres,
//! Z-up, with the front-left machine pin (MP1) at the footprint origin and the contact
//! plane at the footprint's `z`. This verb adds ONE root node carrying that transform,
//! verifies every MP/MC marker lands on the footprint contract, and refuses anything it
//! cannot vouch for (adom/adom-step2glb#10).
//!
//! Scope is deliberately narrow: one qualified source frame. It is not an arbitrary-GLB
//! importer; a GLB from somewhere else needs its own verified frame added here.
//!
//! Local and deterministic: nothing is uploaded, and the output is reproducible from the
//! same inputs (the report carries both sha256s).

use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::path::Path;

const GLB_MAGIC: u32 = 0x4654_6C67; // "glTF"
const CHUNK_JSON: u32 = 0x4E4F_534A; // "JSON"

/// Frames this verb knows how to place. Each one fixes the unit and the axis convention.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceFrame {
    /// tscircuit board build (`tsci build --glbs` → dist/lib/index/3d.glb): millimetres,
    /// Y-up, cad components named by their refdes (MP1..MP4, MC1..).
    TscircuitMmYup,
}

impl SourceFrame {
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_ascii_lowercase().as_str() {
            "tscircuit" | "tscircuit-mm-yup" | "tscircuit-mm-y-up" => Some(Self::TscircuitMmYup),
            _ => None,
        }
    }
    pub const SUPPORTED: &'static [&'static str] = &["tscircuit (millimetres, Y-up; alias tscircuit-mm-yup)"];
    pub fn name(self) -> &'static str {
        match self {
            Self::TscircuitMmYup => "tscircuit",
        }
    }
    pub fn unit(self) -> &'static str {
        match self {
            Self::TscircuitMmYup => "mm",
        }
    }
    pub fn up_axis(self) -> &'static str {
        match self {
            Self::TscircuitMmYup => "y",
        }
    }
}

pub struct AnchorOptions {
    pub frame: SourceFrame,
    /// Position tolerance in the source unit (mm) when checking markers against the footprint.
    pub tolerance: f64,
}

#[derive(Debug)]
pub struct AnchorOutcome {
    pub glb: Vec<u8>,
    pub report: Value,
}

struct Glb {
    json: Value,
    /// Every byte after the JSON chunk, kept verbatim (BIN chunk header included).
    tail: Vec<u8>,
}

fn unpack(raw: &[u8]) -> Result<Glb, String> {
    if raw.len() < 20 {
        return Err("not a GLB: shorter than the 20-byte header".into());
    }
    let u32_at = |i: usize| u32::from_le_bytes([raw[i], raw[i + 1], raw[i + 2], raw[i + 3]]);
    if u32_at(0) != GLB_MAGIC {
        return Err("not a GLB: missing glTF magic".into());
    }
    if u32_at(4) != 2 {
        return Err(format!("GLB version {} is not 2", u32_at(4)));
    }
    let length = u32_at(8) as usize;
    if length != raw.len() {
        return Err(format!("GLB header says {length} bytes, file is {}", raw.len()));
    }
    let json_len = u32_at(12) as usize;
    if u32_at(16) != CHUNK_JSON {
        return Err("first GLB chunk is not JSON".into());
    }
    if 20 + json_len > raw.len() {
        return Err("JSON chunk runs past the end of the file".into());
    }
    let json: Value = serde_json::from_slice(&raw[20..20 + json_len])
        .map_err(|e| format!("GLB JSON chunk does not parse: {e}"))?;
    Ok(Glb { json, tail: raw[20 + json_len..].to_vec() })
}

fn pack(json: &Value, tail: &[u8]) -> Vec<u8> {
    let mut encoded = serde_json::to_vec(json).expect("glTF JSON serialises");
    while encoded.len() % 4 != 0 {
        encoded.push(b' ');
    }
    let total = 20 + encoded.len() + tail.len();
    let mut out = Vec::with_capacity(total);
    out.extend_from_slice(&GLB_MAGIC.to_le_bytes());
    out.extend_from_slice(&2u32.to_le_bytes());
    out.extend_from_slice(&(total as u32).to_le_bytes());
    out.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
    out.extend_from_slice(&CHUNK_JSON.to_le_bytes());
    out.extend_from_slice(&encoded);
    out.extend_from_slice(tail);
    out
}

struct Marker {
    name: String,
    x: f64,
    y: f64,
    z: f64,
}

/// The footprint contract `convert --molecule` writes beside the GLB: `machinePins` and
/// `contacts`, each with `name`, `position {x, y}` (mm, MP1 at the origin) and `z` (mm,
/// the contact plane). `originPin` names the anchor when present.
fn read_footprint(fp: &Value) -> Result<(String, Vec<Marker>), String> {
    let mut markers = Vec::new();
    for key in ["machinePins", "contacts"] {
        let arr = fp
            .get(key)
            .and_then(|v| v.as_array())
            .ok_or_else(|| format!("footprint JSON has no `{key}` array; is this the *_footprint.json from `convert --molecule`?"))?;
        for p in arr {
            let name = p.get("name").and_then(|v| v.as_str()).ok_or("footprint entry without a name")?.to_string();
            let pos = p.get("position").ok_or_else(|| format!("{name}: no position"))?;
            let x = pos.get("x").and_then(|v| v.as_f64()).ok_or_else(|| format!("{name}: position.x"))?;
            let y = pos.get("y").and_then(|v| v.as_f64()).ok_or_else(|| format!("{name}: position.y"))?;
            let z = p.get("z").and_then(|v| v.as_f64()).ok_or_else(|| format!("{name}: z"))?;
            markers.push(Marker { name, x, y, z });
        }
    }
    if markers.is_empty() {
        return Err("footprint JSON lists no machine pins or contacts".into());
    }
    let origin = fp
        .get("originPin")
        .and_then(|v| v.as_str())
        .map(str::to_string)
        .unwrap_or_else(|| "MP1".to_string());
    Ok((origin, markers))
}

fn translation(node: &Value) -> Option<[f64; 3]> {
    let t = node.get("translation")?.as_array()?;
    if t.len() != 3 {
        return None;
    }
    Some([t[0].as_f64()?, t[1].as_f64()?, t[2].as_f64()?])
}

/// Do the work on parsed inputs. Pure: no I/O, so it is testable with synthetic GLBs.
/// A node is a STATE NODE when its name starts with `Adom.LED` or `Adom.LaserEtch` (then `.` or
/// end) or it carries `extras.adomRole` (adom-step2glb#17: John Lauer's LED switch groups and
/// part-number marks). Viewers toggle whole groups by these roots, so they must come out of
/// this verb exactly as they went in.
fn is_state_node(node: &Value) -> bool {
    let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
    let named = ["Adom.LED", "Adom.LaserEtch"].iter().any(|p| name == *p || name.starts_with(&format!("{p}.")));
    named || node.pointer("/extras/adomRole").and_then(|v| v.as_str()).is_some()
}

/// Names of the top-most state nodes (no qualifying ancestor), in node-index order.
fn state_roots(nodes: &[Value]) -> Vec<String> {
    let mut parent = vec![None; nodes.len()];
    for (i, n) in nodes.iter().enumerate() {
        if let Some(ch) = n.get("children").and_then(|v| v.as_array()) {
            for c in ch { if let Some(ci) = c.as_u64() { if (ci as usize) < nodes.len() { parent[ci as usize] = Some(i); } } }
        }
    }
    let mut out = Vec::new();
    for (i, n) in nodes.iter().enumerate() {
        if !is_state_node(n) { continue; }
        let mut p = parent[i]; let mut under = false;
        while let Some(pi) = p { if is_state_node(&nodes[pi]) { under = true; break; } p = parent[pi]; }
        if !under { out.push(n.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string()); }
    }
    out
}

/// Nodes carrying a `KHR_lights_punctual` light.
fn light_nodes(nodes: &[Value]) -> usize {
    nodes.iter().filter(|n| n.pointer("/extensions/KHR_lights_punctual").is_some()).count()
}

pub fn anchor(raw: &[u8], footprint: &Value, opts: &AnchorOptions) -> Result<AnchorOutcome, String> {
    let SourceFrame::TscircuitMmYup = opts.frame;
    let source = unpack(raw)?;
    let original = source.json.clone();
    let mut model = source.json;

    // 1. This verb exists to keep textures. A GLB without any has nothing to preserve and
    //    the STEP path (`convert`) is the right tool.
    // Counts come from the untouched original so no borrow outlives the edits below.
    let count = |k: &str| original.get(k).and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
    if count("textures") == 0 || count("images") == 0 {
        return Err("the GLB has no embedded textures; anchor-glb exists to preserve them. For an untextured model use `step2glb convert --molecule` from the STEP".into());
    }
    let scenes = model.get("scenes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    if scenes.len() != 1 {
        return Err(format!("expected exactly one scene, found {}; this frame is only verified for a single-scene tscircuit build", scenes.len()));
    }
    let nodes = model.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    let roots: Vec<usize> = scenes[0]
        .get("nodes")
        .and_then(|v| v.as_array())
        .map(|a| a.iter().filter_map(|x| x.as_u64().map(|u| u as usize)).collect())
        .unwrap_or_default();
    let index_of = |name: &str| nodes.iter().position(|n| n.get("name").and_then(|v| v.as_str()) == Some(name));

    // 2. The anchor: MP1 (or originPin) must be the footprint origin, and its node in the
    //    GLB gives the translation that carries the whole build onto it.
    let (origin_name, markers) = read_footprint(footprint)?;
    let origin_fp = markers
        .iter()
        .find(|m| m.name == origin_name)
        .ok_or_else(|| format!("footprint has no `{origin_name}` marker to anchor on"))?;
    if origin_fp.x != 0.0 || origin_fp.y != 0.0 {
        return Err(format!("footprint origin pin {origin_name} is at ({}, {}), not (0, 0); the contract puts the front-left pin at the origin", origin_fp.x, origin_fp.y));
    }
    let origin_idx = index_of(&origin_name).ok_or_else(|| format!("GLB has no node named `{origin_name}`; tscircuit names cad components by refdes, so the board must carry the machine pins as components"))?;
    let o = translation(&nodes[origin_idx]).ok_or_else(|| format!("node `{origin_name}` has no translation"))?;
    // tscircuit (x, y, z) mm Y-up  →  molecule (-x, z, y) + offset, mm Z-up. A proper
    // rotation (determinant +1), so no mirrored geometry; the sign on x is what keeps it so.
    let offset = [o[0], -o[2], origin_fp.z - o[1]];

    // 3. Every marker in the footprint must exist as a scene-root node (a nested node's
    //    translation would not be its world position) and land on its contract position.
    let mut positions = Map::new();
    let mut errors = Vec::new();
    for m in &markers {
        let idx = match index_of(&m.name) {
            Some(i) => i,
            None => { errors.push(format!("{}: no node of that name in the GLB", m.name)); continue; }
        };
        if !roots.contains(&idx) {
            errors.push(format!("{}: node is not a scene root, its translation is not a world position", m.name));
            continue;
        }
        if nodes[idx].get("matrix").is_some() {
            errors.push(format!("{}: node carries a matrix; only translation-placed markers are verified", m.name));
            continue;
        }
        let t = match translation(&nodes[idx]) {
            Some(t) => t,
            None => { errors.push(format!("{}: node has no translation", m.name)); continue; }
        };
        let actual = [-t[0] + offset[0], t[2] + offset[1], t[1] + offset[2]];
        let expected = [m.x, m.y, m.z];
        let off = actual.iter().zip(expected.iter()).map(|(a, b)| (a - b).abs()).fold(0.0_f64, f64::max);
        if off > opts.tolerance {
            errors.push(format!(
                "{}: lands at ({:.4}, {:.4}, {:.4}) mm, footprint says ({:.4}, {:.4}, {:.4}); off by {:.4} mm (tolerance {})",
                m.name, actual[0], actual[1], actual[2], expected[0], expected[1], expected[2], off, opts.tolerance
            ));
            continue;
        }
        positions.insert(m.name.clone(), json!([actual[0], actual[1], actual[2]]));
    }
    if !errors.is_empty() {
        return Err(format!("attachment contract not met:\n  {}", errors.join("\n  ")));
    }

    // 4. One new root, everything else untouched. Column-major glTF matrix:
    //    columns are the images of x, y, z, then the translation; mm → m on the way.
    let pin_size = footprint
        .pointer("/machinePins/0/contactType")
        .and_then(|v| v.as_str())
        .map(|s| if s.starts_with("large") { "large" } else { "medium" })
        .unwrap_or("medium");
    let root = json!({
        "name": "MoleculeFrame",
        "children": roots,
        "matrix": [
            -0.001, 0.0, 0.0, 0.0,
             0.0,   0.0, 0.001, 0.0,
             0.0,   0.001, 0.0, 0.0,
             offset[0] / 1000.0, offset[1] / 1000.0, offset[2] / 1000.0, 1.0
        ]
    });
    let new_index = nodes.len();
    model["nodes"].as_array_mut().unwrap().push(root);
    model["scenes"][0]["nodes"] = json!([new_index]);
    let stamp = json!({
        "anchored": true,
        "upAxis": "z",
        "unit": "m",
        "flMarker": origin_name,
        "pinSize": pin_size,
        "footprint": true,
        "contactZ": origin_fp.z,
        "texturesPreserved": true,
        "statePreserved": true,
        "source": format!("{} detailed GLB ({}, {}-up)", opts.frame.name(), opts.frame.unit(), opts.frame.up_axis()),
        "sourceFrame": opts.frame.name(),
        "anchoredBy": format!("step2glb {}", env!("CARGO_PKG_VERSION")),
    });
    let asset = model["asset"].as_object_mut().ok_or("GLB has no asset object")?;
    let extras = asset.entry("extras").or_insert_with(|| json!({}));
    extras["adomMolecule"] = stamp.clone();

    // 5. Prove the promise: the bytes after the JSON chunk and every geometry/material
    //    section are exactly what came in.
    let out = pack(&model, &source.tail);
    let check = unpack(&out)?;
    if check.tail != source.tail {
        return Err("internal: binary payload changed during repack".into());
    }
    for key in ["meshes", "materials", "textures", "images", "buffers", "bufferViews", "accessors", "samplers", "extensions", "extensionsUsed", "extensionsRequired"] {
        if check.json.get(key) != original.get(key) {
            return Err(format!("internal: `{key}` changed during repack"));
        }
    }
    // 6. Lights and state roots (adom-step2glb#17): what went in must come out. The only node
    //    edit above is the new root, so a difference here is a bug in this verb, refused
    //    rather than shipped, the same way a displaced marker is.
    let nodes_in = original.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    let nodes_out = check.json.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    let (lights_in, lights_out) = (light_nodes(&nodes_in), light_nodes(&nodes_out));
    let (roots_in, roots_out) = (state_roots(&nodes_in), state_roots(&nodes_out));
    if lights_in != lights_out || roots_in != roots_out {
        return Err(format!("internal: lights {lights_in} -> {lights_out}, state roots {roots_in:?} -> {roots_out:?} changed during anchoring"));
    }

    let report = json!({
        "sourceSha256": hex(&Sha256::digest(raw)),
        "publishedSha256": hex(&Sha256::digest(&out)),
        "sourceFrame": opts.frame.name(),
        "sourceUnit": opts.frame.unit(),
        "sourceUpAxis": opts.frame.up_axis(),
        "targetUnit": "m",
        "targetUpAxis": "z",
        "toleranceMm": opts.tolerance,
        "binaryPayloadPreserved": true,
        "attachmentPositionsMm": positions,
        "meshes": count("meshes"),
        "materials": count("materials"),
        "textures": count("textures"),
        "images": count("images"),
        "lights": lights_out,
        "stateRoots": roots_out.len(),
        "stateRootNames": roots_out,
        "lightsPreserved": true,
        "stateRootsPreserved": true,
        "conversion": stamp,
    });
    Ok(AnchorOutcome { glb: out, report })
}

fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

pub fn anchor_files(glb: &Path, footprint: &Path, opts: &AnchorOptions) -> Result<AnchorOutcome, String> {
    let raw = std::fs::read(glb).map_err(|e| format!("read {}: {e}", glb.display()))?;
    let fp: Value = serde_json::from_slice(
        &std::fs::read(footprint).map_err(|e| format!("read {}: {e}", footprint.display()))?,
    )
    .map_err(|e| format!("{} is not JSON: {e}", footprint.display()))?;
    anchor(&raw, &fp, opts)
}

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

    /// A minimal textured GLB whose MP/MC nodes sit where a tscircuit build would put them
    /// for a footprint with MP1 at (0,0), MP4 at (8,0), MC1 at (0,2), contact z = 2.8.
    /// tscircuit x = -fp.x + ox, z = fp.y - oy... we just invert the verb's own mapping.
    fn synthetic(fp: &Value, textured: bool, nudge: Option<(&str, f64)>) -> Vec<u8> {
        let ox = 96.0; let oz = -104.0; let oy = 2.8 - 0.0; // arbitrary build offsets
        let mut nodes = Vec::new();
        let mut roots = Vec::new();
        for key in ["machinePins", "contacts"] {
            for p in fp[key].as_array().unwrap() {
                let name = p["name"].as_str().unwrap();
                let (x, y, z) = (p["position"]["x"].as_f64().unwrap(), p["position"]["y"].as_f64().unwrap(), p["z"].as_f64().unwrap());
                // molecule (X, Y, Z) = (-tx + offx, tz + offy, ty + offz) with offset chosen so
                // MP1 maps to (0, 0, z): tx = ox - X, tz = Y + oz, ty = Z - oy.
                let mut t = [ox - x, z - oy, y + oz];
                if let Some((n, d)) = nudge { if n == name { t[0] += d; } }
                roots.push(nodes.len());
                nodes.push(json!({ "name": name, "translation": t, "mesh": 0 }));
            }
        }
        nodes.push(json!({ "name": "board", "mesh": 0 }));
        roots.push(nodes.len() - 1);
        let mut model = json!({
            "asset": { "version": "2.0", "generator": "test" },
            "scene": 0,
            "scenes": [{ "nodes": roots }],
            "nodes": nodes,
            "meshes": [{ "primitives": [{ "attributes": { "POSITION": 0 } }] }],
            "accessors": [{ "bufferView": 0, "componentType": 5126, "count": 1, "type": "VEC3" }],
            "bufferViews": [{ "buffer": 0, "byteLength": 12 }],
            "buffers": [{ "byteLength": 12 }],
            "materials": [{ "name": "copper" }],
        });
        if textured {
            model["textures"] = json!([{ "source": 0 }]);
            model["images"] = json!([{ "bufferView": 0, "mimeType": "image/png" }]);
        }
        let bin: Vec<u8> = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
        let mut tail = Vec::new();
        tail.extend_from_slice(&(bin.len() as u32).to_le_bytes());
        tail.extend_from_slice(&0x004E_4942u32.to_le_bytes()); // "BIN\0"
        tail.extend_from_slice(&bin);
        pack(&model, &tail)
    }

    fn footprint() -> Value {
        json!({
            "originPin": "MP1",
            "machinePins": [
                { "name": "MP1", "contactType": "medium_pin_short", "position": { "x": 0, "y": 0 }, "z": 2.8 },
                { "name": "MP4", "contactType": "medium_pin_short", "position": { "x": 8, "y": 0 }, "z": 2.8 },
                { "name": "MP3", "contactType": "medium_pin_short", "position": { "x": 8, "y": 8 }, "z": 2.8 },
                { "name": "MP2", "contactType": "medium_pin_short", "position": { "x": 0, "y": 8 }, "z": 2.8 }
            ],
            "contacts": [
                { "name": "MC1", "contactType": "medium_contact", "position": { "x": 0, "y": 2 }, "z": 2.8 },
                { "name": "MC2", "contactType": "medium_contact", "position": { "x": 0, "y": 6 }, "z": 2.8 }
            ]
        })
    }

    fn opts() -> AnchorOptions { AnchorOptions { frame: SourceFrame::TscircuitMmYup, tolerance: 1e-4 } }

    #[test]
    fn anchors_and_preserves_everything() {
        let fp = footprint();
        let raw = synthetic(&fp, true, None);
        let out = anchor(&raw, &fp, &opts()).expect("anchors");
        let g = unpack(&out.glb).unwrap();
        let src = unpack(&raw).unwrap();
        assert_eq!(g.tail, src.tail, "binary payload byte-identical");
        for k in ["meshes", "materials", "textures", "images", "buffers", "bufferViews", "accessors"] {
            assert_eq!(g.json[k], src.json[k], "{k} untouched");
        }
        let root_idx = g.json["scenes"][0]["nodes"][0].as_u64().unwrap() as usize;
        let root = &g.json["nodes"][root_idx];
        assert_eq!(root["name"], "MoleculeFrame");
        assert_eq!(root["children"].as_array().unwrap().len(), 7, "all former roots reparented");
        let m: Vec<f64> = root["matrix"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
        assert_eq!(&m[..12], &[-0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001, 0.0, 0.0, 0.001, 0.0, 0.0]);
        assert_eq!(g.json["asset"]["extras"]["adomMolecule"]["anchored"], true);
        assert_eq!(g.json["asset"]["extras"]["adomMolecule"]["flMarker"], "MP1");
        assert_eq!(g.json["asset"]["extras"]["adomMolecule"]["contactZ"], 2.8);
        let pos = &out.report["attachmentPositionsMm"];
        assert_eq!(pos["MP1"], json!([0.0, 0.0, 2.8]));
        assert_eq!(pos["MP3"], json!([8.0, 8.0, 2.8]));
        assert_eq!(pos["MC2"], json!([0.0, 6.0, 2.8]));
        assert_eq!(out.report["textures"], 1);
        assert_eq!(out.report["binaryPayloadPreserved"], true);
    }

    /// John Lauer's LED structure grafted onto the synthetic build: Adom.LED.State at scene
    /// root owning Adom.LED.Emission (a mesh) and Adom.LED.Light (a KHR_lights_punctual spot),
    /// plus a root-level Adom.LaserEtch.MPN. Everything must come out intact, under
    /// MoleculeFrame, with the light definition and extensionsUsed untouched.
    fn synthetic_with_led(fp: &Value) -> Vec<u8> {
        let raw = synthetic(fp, true, None);
        let g = unpack(&raw).unwrap();
        let mut model = g.json;
        let nodes = model["nodes"].as_array_mut().unwrap();
        let base = nodes.len();
        nodes.push(json!({ "name": "Adom.LED.Emission", "mesh": 0, "extras": { "adomRole": "led-emission" }, "translation": [0.0, 0.00035, 0.0] }));
        nodes.push(json!({ "name": "Adom.LED.Light", "extras": { "adomRole": "led-light" }, "translation": [0.0, 0.000516, 0.0],
                           "extensions": { "KHR_lights_punctual": { "light": 0 } } }));
        nodes.push(json!({ "name": "Adom.LED.State", "children": [base, base + 1], "extras": { "adomRole": "led-state", "defaultEnabled": true } }));
        nodes.push(json!({ "name": "Adom.LaserEtch.MPN", "mesh": 0, "extras": { "adomRole": "mpn-marking" } }));
        let roots = model["scenes"][0]["nodes"].as_array_mut().unwrap();
        roots.push(json!(base + 2));
        roots.push(json!(base + 3));
        model["extensions"] = json!({ "KHR_lights_punctual": { "lights": [{ "name": "Adom.LED.Light", "type": "spot", "color": [0.015, 1.0, 0.07],
            "intensity": 7.5e-5, "range": 0.015, "spot": { "innerConeAngle": 0.9745, "outerConeAngle": 1.4835 },
            "extras": { "viewingAngleFwhmDegrees": 130 } }] } });
        model["extensionsUsed"] = json!(["KHR_lights_punctual"]);
        pack(&model, &g.tail)
    }

    #[test]
    fn lights_and_state_roots_survive_anchoring() {
        let fp = footprint();
        let raw = synthetic_with_led(&fp);
        let src = unpack(&raw).unwrap();
        let out = anchor(&raw, &fp, &opts()).expect("anchors with a light");
        let g = unpack(&out.glb).unwrap();
        assert_eq!(g.json["extensions"], src.json["extensions"], "light definition untouched");
        assert_eq!(g.json["extensionsUsed"], src.json["extensionsUsed"]);
        let nodes = g.json["nodes"].as_array().unwrap();
        let state = nodes.iter().find(|n| n["name"] == "Adom.LED.State").expect("state root kept");
        assert_eq!(state["children"].as_array().unwrap().len(), 2, "state root keeps both children");
        assert_eq!(state["extras"]["adomRole"], "led-state");
        let light = nodes.iter().find(|n| n["name"] == "Adom.LED.Light").expect("light node kept");
        assert_eq!(light["extensions"]["KHR_lights_punctual"]["light"], 0);
        let root_idx = g.json["scenes"][0]["nodes"][0].as_u64().unwrap() as usize;
        let kids = g.json["nodes"][root_idx]["children"].as_array().unwrap();
        let state_idx = nodes.iter().position(|n| n["name"] == "Adom.LED.State").unwrap() as u64;
        assert!(kids.iter().any(|k| k.as_u64() == Some(state_idx)), "state root sits under MoleculeFrame");
        assert_eq!(out.report["lights"], 1);
        assert_eq!(out.report["stateRoots"], 2);
        assert_eq!(out.report["stateRootNames"], json!(["Adom.LED.State", "Adom.LaserEtch.MPN"]));
        assert_eq!(out.report["conversion"]["statePreserved"], true);
    }

    #[test]
    fn a_displaced_marker_is_refused() {
        let fp = footprint();
        let raw = synthetic(&fp, true, Some(("MC2", 0.05)));
        let e = anchor(&raw, &fp, &opts()).unwrap_err();
        assert!(e.contains("MC2"), "{e}");
        assert!(e.contains("off by 0.0500 mm"), "{e}");
    }

    #[test]
    fn a_glb_without_textures_is_refused() {
        let fp = footprint();
        let raw = synthetic(&fp, false, None);
        let e = anchor(&raw, &fp, &opts()).unwrap_err();
        assert!(e.contains("no embedded textures"), "{e}");
    }

    #[test]
    fn an_origin_pin_off_zero_is_refused() {
        let mut fp = footprint();
        fp["machinePins"][0]["position"]["x"] = json!(1.0);
        let raw = synthetic(&fp, true, None);
        let e = anchor(&raw, &fp, &opts()).unwrap_err();
        assert!(e.contains("not (0, 0)"), "{e}");
    }

    #[test]
    fn output_is_deterministic() {
        let fp = footprint();
        let raw = synthetic(&fp, true, None);
        let a = anchor(&raw, &fp, &opts()).unwrap();
        let b = anchor(&raw, &fp, &opts()).unwrap();
        assert_eq!(a.glb, b.glb);
        assert_eq!(a.report["publishedSha256"], b.report["publishedSha256"]);
    }

    #[test]
    fn frame_names() {
        assert_eq!(SourceFrame::parse("tscircuit"), Some(SourceFrame::TscircuitMmYup));
        assert_eq!(SourceFrame::parse("TSCIRCUIT-MM-YUP"), Some(SourceFrame::TscircuitMmYup));
        assert_eq!(SourceFrame::parse("kicad"), None);
    }
}