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.3
9 files changed
+109−8
Cargo.lock+1−1@@ -609,7 +609,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "step2glb"-version = "0.13.0"+version = "0.13.2" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "step2glb"-version = "0.13.1"+version = "0.13.2" edition = "2021" description = "STEP → GLB converter that preserves colors from the source CAD via OCCT's XCAF pipeline. CLI + Hydrogen webview app." license = "MIT"
README.md+2@@ -201,6 +201,8 @@ All POSTs return `202 {"job_id": …, "poll": "/jobs/<id>"}`. Poll, then downloa ## What's new +**Service 1.3.2 + CLI 0.13.2** (2026-09-17): `anchor-glb` now counts KHR_lights_punctual lights and the `Adom.LED.*` / `Adom.LaserEtch.*` state groups that go in and come out, refuses on any difference, and records them in the model-quality report (adom/adom-step2glb#17, item 1; the service side keeps them through optimize as well).+ **Service 1.3.1 + CLI 0.13.1** (2026-09-17), all four filed by John Lauer with exact repros: - **Fixed #13, #14, #15:** `/laser-etch` now fits the part number to the usable top face. Every upward planar face within a band below the highest one is a candidate (the body top between raised terminals counts, so CL21B105KBFNNNE no longer fails with "no top face found at zmax"); both in-plane orientations are compared and the larger font wins (TPSM365R6V5RDNR reads along its 4.5 mm side); the text is scaled to a sampled rectangle proven to lie on the face and not under any higher face, with no minimum font (a 0402 gets 0.52 mm of text on its 0.6 mm body instead of 1.66 mm hanging off both ends), and glyph thickness follows the font. The job status carries `fit` (face, axis, font size, extents, an overflow check, every candidate) so `complete` cannot hide a bad mark. A side-lying source frame is reported as `fit.frame_hint`, never rotated. After John's `fit_mpn_surface.py` (PR #1).
VERSION+1−1@@ -1 +1 @@-0.13.1+0.13.2
adom-step2glb⋯ 1 unchanged line ⋯
adom-step2glb-release⋯ 1 unchanged line ⋯
package.json+2−2@@ -1,6 +1,6 @@ { "slug": "adom-step2glb",- "version": "1.3.2",+ "version": "1.3.3", "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.3.2 (CLI 0.13.1): service 1.3.1 fits the laser-etch part number to the usable top face, both orientations, no minimum font, raised terminals avoided, fit report on the job status (adom/adom-step2glb#13 #14 #15, after John Lauer's PR #1); in-flight jobs never expire and a finished job answers 410 status=expired instead of 404 (adom/adom-step2glb#16). CLI: the poll loops say whether a job expired or was never known, carrying the service's hint, and handle status=expired. 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."+ "changelog": "1.3.3 (CLI 0.13.2): anchor-glb counts KHR_lights_punctual lights and Adom.LED.* / Adom.LaserEtch.* state groups in and out, refuses on a difference, records them in the model-quality report (adom/adom-step2glb#17 item 1; service 1.3.2 keeps them through optimize). 1.3.2 (CLI 0.13.1): service 1.3.1 fits the laser-etch part number to the usable top face, both orientations, no minimum font, raised terminals avoided, fit report on the job status (adom/adom-step2glb#13 #14 #15, after John Lauer's PR #1); in-flight jobs never expire and a finished job answers 410 status=expired instead of 404 (adom/adom-step2glb#16). CLI: the poll loops say whether a job expired or was never known, carrying the service's hint, and handle status=expired. 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.3.2",+ "version": "1.3.3", "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.3.2 (CLI 0.13.1): service 1.3.1 fits the laser-etch part number to the usable top face, both orientations, no minimum font, raised terminals avoided, fit report on the job status (adom/adom-step2glb#13 #14 #15, after John Lauer's PR #1); in-flight jobs never expire and a finished job answers 410 status=expired instead of 404 (adom/adom-step2glb#16). CLI: the poll loops say whether a job expired or was never known, carrying the service's hint, and handle status=expired. 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."+ "changelog": "1.3.3 (CLI 0.13.2): anchor-glb counts KHR_lights_punctual lights and Adom.LED.* / Adom.LaserEtch.* state groups in and out, refuses on a difference, records them in the model-quality report (adom/adom-step2glb#17 item 1; service 1.3.2 keeps them through optimize). 1.3.2 (CLI 0.13.1): service 1.3.1 fits the laser-etch part number to the usable top face, both orientations, no minimum font, raised terminals avoided, fit report on the job status (adom/adom-step2glb#13 #14 #15, after John Lauer's PR #1); in-flight jobs never expire and a finished job answers 410 status=expired instead of 404 (adom/adom-step2glb#16). CLI: the poll loops say whether a job expired or was never known, carrying the service's hint, and handle status=expired. 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.rs+100−1@@ -163,6 +163,39 @@ fn translation(node: &Value) -> Option<[f64; 3]> { } /// 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)?;@@ -270,6 +303,7 @@ pub fn anchor(raw: &[u8], footprint: &Value, opts: &AnchorOptions) -> Result<Anc "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")),@@ -285,11 +319,21 @@ pub fn anchor(raw: &[u8], footprint: &Value, opts: &AnchorOptions) -> Result<Anc 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"] {+ 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)),@@ -306,6 +350,11 @@ pub fn anchor(raw: &[u8], footprint: &Value, opts: &AnchorOptions) -> Result<Anc "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 })@@ -418,6 +467,56 @@ mod tests { 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();