← Commit history
Cargo.lock+96−1
@@ -70,6 +70,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]+ [[package]] name = "cc" version = "1.2.60"@@ -147,6 +156,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]+ [[package]] name = "crc32fast" version = "1.5.0"@@ -156,6 +174,26 @@ dependencies = [  "cfg-if", ] +[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]+ [[package]] name = "displaydoc" version = "0.2.5"@@ -167,6 +205,12 @@ dependencies = [  "syn", ] +[[package]]+name = "equivalent"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"+ [[package]] name = "find-msvc-tools" version = "0.1.9"@@ -192,6 +236,16 @@ dependencies = [  "percent-encoding", ] +[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]+ [[package]] name = "getrandom" version = "0.2.17"@@ -203,6 +257,12 @@ dependencies = [  "wasi", ] +[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"+ [[package]] name = "heck" version = "0.5.0"@@ -318,6 +378,16 @@ dependencies = [  "icu_properties", ] +[[package]]+name = "indexmap"+version = "2.14.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"+dependencies = [+ "equivalent",+ "hashbrown",+]+ [[package]] name = "is_terminal_polyfill" version = "1.70.2"@@ -494,6 +564,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [+ "indexmap",  "itoa",  "memchr",  "serde",@@ -501,6 +572,17 @@ dependencies = [  "zmij", ] +[[package]]+name = "sha2"+version = "0.10.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]+ [[package]] name = "shlex" version = "1.3.0"@@ -527,7 +609,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"  [[package]] name = "step2glb"-version = "0.12.2"+version = "0.13.0" dependencies = [  "base64",  "clap",@@ -535,6 +617,7 @@ dependencies = [  "flate2",  "serde",  "serde_json",+ "sha2",  "tiny_http",  "ureq", ]@@ -595,6 +678,12 @@ dependencies = [  "zerovec", ] +[[package]]+name = "typenum"+version = "1.20.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"+ [[package]] name = "unicode-ident" version = "1.0.24"@@ -649,6 +738,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"+ [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1"
Cargo.toml+3−2
@@ -1,6 +1,6 @@ [package] name = "step2glb"-version = "0.12.2"+version = "0.13.0" edition = "2021" description = "STEP → GLB converter that preserves colors from the source CAD via OCCT's XCAF pipeline. CLI + Hydrogen webview app." license = "MIT"@@ -13,11 +13,12 @@ path = "src/main.rs" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" serde = { version = "1", features = ["derive"] }-serde_json = "1"+serde_json = { version = "1", features = ["float_roundtrip", "preserve_order"] } tiny_http = "0.12" ureq = { version = "2", features = ["json"] } base64 = "0.22" flate2 = "1"+sha2 = "0.10"  [profile.release] strip = true
README.md+10
@@ -71,6 +71,16 @@ step2glb convert board.step --board board.kicad_pcb --pin medium -o my-board.glb  One command runs the whole server-side molecule pipeline: MP-marker anchoring (Z-up, front-left machine pin at origin), canonical gold machine-pin material, silkscreen overlay, meter normalization, Draco optimize, and, with a board attached, **footprint + symbol JSON** with per-pad wire-attach Z, downloaded beside the GLB as `<stem>_footprint.json` / `<stem>_symbol.json`. The final GLB carries `asset.extras.adomMolecule` provenance. Gate on the printed stats before publishing (the molecule-publish skill treats them as stop-signs). +### Textured board GLB: keep it, just add the frame (`anchor-glb`)++```bash+tsci build lib/index.tsx --glbs                       # tscircuit: dist/lib/index/3d.glb with traces + silkscreen textures+step2glb convert board.step --board board.kicad_pcb  # once, for the attachment contract: <stem>_footprint.json+step2glb anchor-glb dist/lib/index/3d.glb --footprint my-board_footprint.json -o my-board.glb+```++`convert --molecule` starts from STEP, and STEP cannot carry the glTF textures a tscircuit build already has, so publishing the STEP-derived GLB gave readers a plainer model than the design preview. `anchor-glb` takes the detailed GLB as-is and adds **one** root node (`MoleculeFrame`) with the molecule transform: millimetres/Y-up to metres/Z-up, MP1 at the footprint origin, contact plane at the footprint `z`. Nothing else changes: every mesh, material, texture, image, accessor and the whole binary payload are byte-identical, and the verb checks that before writing. Every MP/MC marker in the footprint must exist as a scene-root node and land on its contract position (`--tolerance`, default 0.0001 mm) or the verb refuses and writes nothing. The GLB gets `asset.extras.adomMolecule` (`texturesPreserved: true`, `sourceFrame: tscircuit`) and a `<out>.model-quality.json` report beside it with both sha256s and the verified positions. Local and deterministic: no upload. `--source-frame` is explicit and only `tscircuit` is supported; a GLB from another tool is refused rather than guessed at.+ ### Thumbnails: one pose or the whole family  ```bash
SKILL.md+24
@@ -61,6 +61,29 @@ The final GLB carries `asset.extras.adomMolecule` (anchored, pin size, etc.) — durable provenance you can check on any downloaded GLB. (Do NOT look for a `molecule_anchor` node: the optimizer flattens it by design.) +## Textured board GLB (v0.13.0+): `anchor-glb` keeps the detailed model++When the board is a tscircuit build, `tsci build --glbs` already produced a GLB+with embedded trace/silkscreen textures; the STEP route loses them. Do NOT+publish the STEP-derived GLB for such a board. Instead:++```bash+step2glb convert board.step --board board.kicad_pcb -o stage.glb   # for <stem>_footprint.json only+step2glb anchor-glb dist/lib/index/3d.glb --footprint stage_footprint.json -o molecule-<slug>.glb+```++`anchor-glb` adds one `MoleculeFrame` root (mm/Y-up -> m/Z-up, MP1 at the+footprint origin, contact plane at footprint `z`) and changes nothing else:+meshes, materials, textures, images, accessors and the binary payload are+byte-identical and verified. It refuses (exit 4, nothing written) when the GLB+has no textures, has more than one scene, or any MP/MC marker is missing, not+a scene root, or off its footprint position by more than `--tolerance` mm.+`--source-frame` must be stated; only `tscircuit` exists. The output carries+`asset.extras.adomMolecule.texturesPreserved: true` and a+`<out>.model-quality.json` (source/published sha256, verified positions).+Publish THAT GLB as the wiki model and render the hero from it; the STEP+stays on the page as the CAD interchange asset.+ ## Silkscreen overlays work in BOTH modes (v0.12.0+)  `--silk-top` / `--silk-bottom` (white-on-transparent PNG) apply in plain@@ -72,6 +95,7 @@ a silent no-op. Composited copper/mask art (e.g. from Gerbers) works too.  ```bash step2glb features part.step          # bbox, floor_z, longest axis, pin1 → JSON+step2glb anchor-glb board.glb --footprint f.json  # textured GLB -> molecule frame, verified, nothing regenerated step2glb thumbnail part.step         # PNG render (--width/--height/--pose) step2glb thumbnail-batch part.step   # N orientations x M sizes in one call (async; CLI polls for you) step2glb config                      # show the service URL in effect
VERSION+1−1
@@ -1 +1 @@-0.12.2+0.13.0
adom-step2glb
⋯ 1 unchanged line ⋯
adom-step2glb-release
⋯ 1 unchanged line ⋯
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "adom-step2glb",-  "version": "1.2.4",+  "version": "1.3.0",   "type": "app",   "description": "The Adom STEP engine: fully manage STEP files. Convert to color-preserving GLB, render thumbnails and vector outlines, laser-etch MPNs, create and edit STEP geometry, extract features, and run molecule mode. One name for the thin Rust CLI and the shared OCCT service. Formerly 'step2glb' + 'service-step2glb'.",   "tags": [@@ -70,5 +70,5 @@     }   ],   "title": "Adom Step2GLB - The Adom STEP Engine",-  "changelog": "1.2.4 (CLI 0.12.2): `features` polls the service job queue (it printed the 202 job JSON as an error; adom/adom-step2glb#1). 1.2.3 (CLI 0.12.1): `thumbnail` no longer advertises GLB input; it refuses a GLB (by --kind, extension or glTF magic) before upload with a pointer to a glTF viewer (adom/adom-step2glb#11). `thumbnail` also polls the service job queue again, so the command works (0.12.0 printed the 202 job JSON as an error). Reconciles the wiki-only 0.12.0 (silk overlays in plain mode) with GitHub main 0.11.2-0.11.3 (--compress, web-grade LOD twin); source of truth is adom-inc/step2glb."+  "changelog": "1.3.0 (CLI 0.13.0): new `anchor-glb`: put a detailed textured GLB (tscircuit build) into the molecule frame with one root transform, every MP/MC marker verified against the footprint JSON, meshes/materials/textures/binary byte-identical and proven, model-quality report beside the output (adom/adom-step2glb#10). 1.2.4 (CLI 0.12.2): `features` polls the service job queue (it printed the 202 job JSON as an error; adom/adom-step2glb#1). 1.2.3 (CLI 0.12.1): `thumbnail` no longer advertises GLB input; it refuses a GLB (by --kind, extension or glTF magic) before upload with a pointer to a glTF viewer (adom/adom-step2glb#11). `thumbnail` also polls the service job queue again, so the command works (0.12.0 printed the 202 job JSON as an error). Reconciles the wiki-only 0.12.0 (silk overlays in plain mode) with GitHub main 0.11.2-0.11.3 (--compress, web-grade LOD twin); source of truth is adom-inc/step2glb." }
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "adom-step2glb",-  "version": "1.2.4",+  "version": "1.3.0",   "type": "app",   "description": "The Adom STEP engine: fully manage STEP files. Convert to color-preserving GLB, render thumbnails and vector outlines, laser-etch MPNs, create and edit STEP geometry, extract features, and run molecule mode. One name for the thin Rust CLI and the shared OCCT service. Formerly 'step2glb' + 'service-step2glb'.",   "tags": [@@ -79,5 +79,5 @@   "install": {     "binary_name": "step2glb"   },-  "changelog": "1.2.4 (CLI 0.12.2): `features` polls the service job queue (it printed the 202 job JSON as an error; adom/adom-step2glb#1). 1.2.3 (CLI 0.12.1): `thumbnail` no longer advertises GLB input; it refuses a GLB (by --kind, extension or glTF magic) before upload with a pointer to a glTF viewer (adom/adom-step2glb#11). `thumbnail` also polls the service job queue again, so the command works (0.12.0 printed the 202 job JSON as an error). Reconciles the wiki-only 0.12.0 (silk overlays in plain mode) with GitHub main 0.11.2-0.11.3 (--compress, web-grade LOD twin); source of truth is adom-inc/step2glb."+  "changelog": "1.3.0 (CLI 0.13.0): new `anchor-glb`: put a detailed textured GLB (tscircuit build) into the molecule frame with one root transform, every MP/MC marker verified against the footprint JSON, meshes/materials/textures/binary byte-identical and proven, model-quality report beside the output (adom/adom-step2glb#10). 1.2.4 (CLI 0.12.2): `features` polls the service job queue (it printed the 202 job JSON as an error; adom/adom-step2glb#1). 1.2.3 (CLI 0.12.1): `thumbnail` no longer advertises GLB input; it refuses a GLB (by --kind, extension or glTF magic) before upload with a pointer to a glTF viewer (adom/adom-step2glb#11). `thumbnail` also polls the service job queue again, so the command works (0.12.0 printed the 202 job JSON as an error). Reconciles the wiki-only 0.12.0 (silk overlays in plain mode) with GitHub main 0.11.2-0.11.3 (--compress, web-grade LOD twin); source of truth is adom-inc/step2glb." }\ No newline at end of file
src/anchor_glb.rsadded+463
@@ -0,0 +1,463 @@+//! `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.+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,+        "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"] {+        if check.json.get(key) != original.get(key) {+            return Err(format!("internal: `{key}` changed during repack"));+        }+    }++    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"),+        "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);+    }++    #[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);+    }+}
src/cli.rs+100
@@ -47,6 +47,9 @@ pub enum Command {     FromLibrary(FromLibraryArgs),     /// Extract geometric features (bbox, floor_z, longest_axis, pin1) from a local STEP without converting.     Features(FeaturesArgs),+    /// Put an existing detailed (textured) GLB into the molecule frame without regenerating+    /// anything: one root transform, every MP/MC marker verified against the footprint JSON.+    AnchorGlb(AnchorGlbArgs),     /// Render a PNG thumbnail of a local STEP (server-side via pyrender; iso pose by default).     Thumbnail(ThumbnailArgs),     /// Render multiple (orientation × size) PNG thumbnails of a local STEP in@@ -117,6 +120,33 @@ pub struct FeaturesArgs {     pub out: Option<PathBuf>, } +#[derive(Args, Debug)]+pub struct AnchorGlbArgs {+    /// The detailed GLB to place, e.g. dist/lib/index/3d.glb from `tsci build --glbs`.+    pub input: PathBuf,+    /// The `<slug>_footprint.json` written by `step2glb convert --molecule` for the SAME+    /// board: MP1 at the origin, contact plane z, every MP/MC position. This is the contract+    /// the GLB is verified against; the GLB never defines it.+    #[arg(long)]+    pub footprint: PathBuf,+    /// Output GLB. Defaults to <input stem>-molecule.glb beside the input.+    #[arg(short, long)]+    pub out: Option<PathBuf>,+    /// Source frame of the input: what unit and up-axis it is in and how markers are named.+    /// Only `tscircuit` (millimetres, Y-up, refdes-named cad components) is supported;+    /// any other value is refused rather than guessed.+    #[arg(long, default_value = "tscircuit")]+    pub source_frame: String,+    /// Largest allowed distance, in mm, between where a marker lands and where the+    /// footprint says it must be.+    #[arg(long, default_value_t = 1e-4)]+    pub tolerance: f64,+    /// Write the model-quality report (hashes, verified positions, counts) to this path.+    /// Defaults to <out stem>.model-quality.json beside the output.+    #[arg(long)]+    pub report: Option<PathBuf>,+}+ #[derive(Args, Debug)] pub struct ThumbnailArgs {     /// Input .step / .stp file. GLB is NOT accepted: the shared service renders from@@ -489,6 +519,76 @@ pub fn features_cmd(args: FeaturesArgs) {     } } +pub fn anchor_glb_cmd(args: AnchorGlbArgs) {+    use crate::anchor_glb::{anchor_files, AnchorOptions, SourceFrame};+    if !args.input.exists() {+        err(format!("input file not found: {}", args.input.display()));+        std::process::exit(1);+    }+    if !args.footprint.exists() {+        err(format!("footprint JSON not found: {}", args.footprint.display()));+        hint("`step2glb convert --molecule --board <board> <board.step>` writes <stem>_footprint.json beside the GLB; that file is the attachment contract this verb verifies against.");+        std::process::exit(1);+    }+    let frame = match SourceFrame::parse(&args.source_frame) {+        Some(f) => f,+        None => {+            err(format!("unsupported --source-frame `{}`", args.source_frame));+            hint(format!("supported: {}. A GLB from another tool needs its frame verified and added to anchor_glb.rs, not guessed.", SourceFrame::SUPPORTED.join("; ")));+            std::process::exit(2);+        }+    };+    if !(args.tolerance > 0.0) {+        err("--tolerance must be a positive number of millimetres".to_string());+        std::process::exit(2);+    }+    let out = args.out.unwrap_or_else(|| {+        let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "model".into());+        args.input.with_file_name(format!("{stem}-molecule.glb"))+    });+    let report_path = args.report.unwrap_or_else(|| {+        let stem = out.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "model".into());+        out.with_file_name(format!("{stem}.model-quality.json"))+    });+    let outcome = match anchor_files(&args.input, &args.footprint, &AnchorOptions { frame, tolerance: args.tolerance }) {+        Ok(o) => o,+        Err(e) => {+            err(format!("anchor refused: {e}"));+            hint("Nothing was written. The input GLB is never modified; fix the board or regenerate the footprint JSON from the same build, then re-run.");+            std::process::exit(4);+        }+    };+    if let Err(e) = std::fs::write(&out, &outcome.glb) {+        err(format!("write {}: {e}", out.display()));+        std::process::exit(1);+    }+    let mut report = outcome.report.clone();+    report["source"] = serde_json::Value::String(args.input.display().to_string());+    report["footprint"] = serde_json::Value::String(args.footprint.display().to_string());+    report["output"] = serde_json::Value::String(out.display().to_string());+    if let Err(e) = std::fs::write(&report_path, serde_json::to_string_pretty(&report).unwrap_or_default() + "\n") {+        err(format!("write {}: {e}", report_path.display()));+        std::process::exit(1);+    }+    let n = report["attachmentPositionsMm"].as_object().map(|m| m.len()).unwrap_or(0);+    ok(format!(+        "molecule frame added to {CYAN}{}{RESET} → {CYAN}{}{RESET}  ({DIM}{} markers verified, {} textures, {} materials preserved, {:.1} KB{RESET})",+        args.input.display(), out.display(), n, report["textures"], report["materials"], outcome.glb.len() as f64 / 1024.0+    ));+    crate::note!("  {DIM}frame{RESET} {} ({}, {}-up) → metres, Z-up   {DIM}report{RESET} {}", frame.name(), frame.unit(), frame.up_axis(), report_path.display());+    println!("{}", serde_json::to_string(&serde_json::json!({+        "ok": true,+        "output": out.display().to_string(),+        "report": report_path.display().to_string(),+        "markersVerified": n,+        "textures": report["textures"],+        "materials": report["materials"],+        "sourceSha256": report["sourceSha256"],+        "publishedSha256": report["publishedSha256"],+        "sourceFrame": frame.name(),+    })).unwrap_or_default());+}+ pub fn thumbnail_cmd(args: ThumbnailArgs) {     if !args.input.exists() {         err(format!("input file not found: {}", args.input.display()));
src/main.rs+2
@@ -1,3 +1,4 @@+mod anchor_glb; mod cli; mod client; mod install;@@ -16,6 +17,7 @@ fn main() {         cli::Command::Convert(c)    => cli::convert_cmd(c),         cli::Command::FromLibrary(c)=> cli::from_library_cmd(c),         cli::Command::Features(c)   => cli::features_cmd(c),+        cli::Command::AnchorGlb(c)  => cli::anchor_glb_cmd(c),         cli::Command::Thumbnail(c)  => cli::thumbnail_cmd(c),         cli::Command::ThumbnailBatch(c) => cli::thumbnail_batch_cmd(c),         cli::Command::Preview(c)    => cli::preview_cmd(c),