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'.
master
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);
}
}