app
Adom Step2GLB - The Adom STEP Engine
Public Made by Adomby adom
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'.
← Commit history
Publish 1.3.0
12 files changed
+703−8
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." }
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),