← Commit history

Fix #218: Z-up + upAxis-stamp board GLB (finalize_board_glb); merge-safe render manifest (merge_render_manifest)

barrett-land ·7157045ab1 ·3mo ago ·parent 45bcb86
4 files changed +364−23
bin/adom-project-manager
⋯ 1 unchanged line ⋯
glb_optimize.mjs+7−1
@@ -26,5 +26,11 @@ const zup = doc.createNode('adom_zup').setRotation([0.70710678, 0, 0, 0.70710678 for (const child of scene.listChildren()) zup.addChild(child); scene.addChild(zup); +// Stamp the up-axis so a consumer keys off the declared axis instead of guessing+// from geometry (issue #218). APM reads asset.extras.adomMolecule.upAxis; the wiki+// viewer assumes Z-up. With the +90°X rotation above, the content is now Z-up.+const asset = doc.getRoot().getAsset();+asset.extras = { ...(asset.extras || {}), adomMolecule: { ...(asset.extras?.adomMolecule || {}), upAxis: 'z' } };+ await io.write(output, doc);-console.log(`primitives ${before} -> ${after}; applied Z-up rotation`);+console.log(`primitives ${before} -> ${after}; applied Z-up rotation + upAxis stamp`);
package.json+2−2
@@ -7,8 +7,8 @@     "adom/adom-lbr": "^2.6.0",     "adom/adom-symbol": "^1.1.8",     "adom/adom-footprint": "^1.0.21",-    "adom/adom-layout-viewer": "^0.2.0",-    "adom/adom-schematic-viewer": "^0.3.0",+    "adom/adom-layout-viewer": "^0.1.0",+    "adom/adom-schematic-viewer": "^0.2.0",     "adom/molecule-publish": "^2.1.2"   },   "tags": [
src/main.rs+355−20
@@ -48,6 +48,12 @@ fn main() { fn render_and_upload(slug: &str) -> bool {     eprintln!("rendering {slug} …");     let (sheets, pcb_svg, glb) = render_board_media(slug);+    // The board GLB from service-kicad is a raw kicad-cli export: glTF-standard+    // Y-up and carrying no up-axis stamp (issue #218). Optimize it (quantize ++    // the +90°X Z-up rotation) and stamp `adomMolecule.upAxis = "z"`, so BOTH the+    // wiki 3D viewer (assumes Z-up) and APM (keys on the stamp) render it flat —+    // no hand-patching, no consumer inferring orientation from geometry.+    let glb = glb.map(|g| finalize_board_glb(&g));     if glb.is_none() && pcb_svg.is_none() && sheets.is_empty() {         eprintln!("nothing rendered (not a KiCad board?)");         return false;@@ -87,35 +93,31 @@ fn render_and_upload(slug: &str) -> bool {         .and_then(|t| serde_json::from_str::<Value>(&t).ok())         .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))         .unwrap_or_else(|| "0.0.0".to_string());-    // prior versions[] from the existing manifest (empty on first archive / legacy pages)-    let mut versions: Vec<Value> = wiki_bytes(slug, "render/manifest.json")+    // Read the existing manifest as our MERGE BASE (issue #218 problem 2). We only+    // overwrite the keys the pipeline owns and preserve everything else — top-level+    // keys a downstream tool added (`viewers`, `viewers_path`, …) and unknown keys+    // on each versions[] entry. A wholesale rewrite silently destroyed those; a+    // 404/error body that isn't really a manifest falls back to an empty base.+    let existing: serde_json::Map<String, Value> = wiki_bytes(slug, "render/manifest.json")         .and_then(|b| serde_json::from_slice::<Value>(&b).ok())-        .and_then(|m| m.get("versions").and_then(|a| a.as_array()).cloned())+        .and_then(|m| m.as_object().cloned())+        .filter(|m| m.contains_key("v") || m.contains_key("glb") || m.contains_key("versions"))         .unwrap_or_default();-    let latest_hash = versions.first().and_then(|v| v.get("hash").and_then(|h| h.as_str())).unwrap_or("");+    // Did the render change vs the last archived version? (drives version archival)+    let latest_hash = existing.get("versions").and_then(|a| a.as_array())+        .and_then(|a| a.first()).and_then(|v| v.get("hash")).and_then(|h| h.as_str())+        .unwrap_or("");     let changed = latest_hash != new_hash;     if changed {-        // stage this version's assets under render/<version>/ and index it+        // stage this version's assets under render/<version>/         let vdir = format!("{rdir}/{version}");         let _ = std::fs::create_dir_all(&vdir);         if let Some(g) = &glb { let _ = std::fs::write(format!("{vdir}/board.glb"), g); }         if let Some(s) = &pcb_svg { let _ = std::fs::write(format!("{vdir}/pcb.svg"), s); }         for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{vdir}/sheet-{i:03}.svg"), svg); }-        // drop any existing entry for this version string, then prepend the fresh one-        versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version.as_str()));-        versions.insert(0, json!({"version": version, "hash": new_hash,-            "glb": glb.is_some(), "pcb": pcb_svg.is_some(), "sheets": sheet_names}));-    }-    let current = versions.first().and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))-        .unwrap_or_else(|| version.clone());-    let manifest = json!({-        "v": 2,-        "current": current,-        "glb": glb.is_some(),-        "pcb": pcb_svg.is_some(),-        "sheets": sheet_names,-        "versions": versions,-    });+    }+    let manifest = merge_render_manifest(&existing, &version, &new_hash, changed,+        glb.is_some(), pcb_svg.is_some(), &sheet_names);     let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());      // Push TARGETED paths (not the whole render/ dir) so previously-archived@@ -536,6 +538,192 @@ fn glb_up_axis(bytes: &[u8]) -> Option<String> {         .get("upAxis")?.as_str().map(|s| s.to_lowercase()) } +/// Build the render manifest MERGE-SAFELY (issue #218 problem 2). Starts from the+/// existing manifest so keys the pipeline does not own survive — unknown top-level+/// keys (`viewers`, `viewers_path`, …) a downstream tool added, and unknown keys on+/// each versions[] entry — overwriting only `v`/`current`/`glb`/`pcb`/`sheets`/+/// `versions[].{version,hash,glb,pcb,sheets}`. When `changed`, the current version's+/// entry is MERGED onto any prior entry for that version (so its extra keys survive)+/// and re-prepended, marking it the newest.+fn merge_render_manifest(+    existing: &serde_json::Map<String, Value>,+    version: &str, new_hash: &str, changed: bool,+    has_glb: bool, has_pcb: bool, sheet_names: &[String],+) -> Value {+    let mut versions: Vec<Value> = existing.get("versions")+        .and_then(|a| a.as_array()).cloned().unwrap_or_default();+    if changed {+        let mut entry = versions.iter()+            .find(|v| v.get("version").and_then(|x| x.as_str()) == Some(version))+            .and_then(|v| v.as_object().cloned())+            .unwrap_or_default();+        entry.insert("version".into(), json!(version));+        entry.insert("hash".into(), json!(new_hash));+        entry.insert("glb".into(), json!(has_glb));+        entry.insert("pcb".into(), json!(has_pcb));+        entry.insert("sheets".into(), json!(sheet_names));+        versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version));+        versions.insert(0, Value::Object(entry));+    }+    let current = versions.first()+        .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))+        .unwrap_or_else(|| version.to_string());+    let mut manifest = existing.clone();+    manifest.insert("v".into(), json!(2));+    manifest.insert("current".into(), json!(current));+    manifest.insert("glb".into(), json!(has_glb));+    manifest.insert("pcb".into(), json!(has_pcb));+    manifest.insert("sheets".into(), json!(sheet_names));+    manifest.insert("versions".into(), json!(versions));+    Value::Object(manifest)+}++/// Make a raw kicad-cli board GLB safe for every consumer (issue #218 problem 1):+/// optimize it if the gltf-transform helper is installed (quantize + bake-friendly+/// merges, and its own +90°X Z-up rotation), then guarantee it is Z-up AND carries+/// `asset.extras.adomMolecule.upAxis = "z"`. Never fails — falls back to the input+/// bytes so a missing `node`/optimizer only costs size, never correctness.+fn finalize_board_glb(glb: &[u8]) -> Vec<u8> {+    let optimized = optimize_glb(glb).unwrap_or_else(|| glb.to_vec());+    ensure_zup_stamp(&optimized)+}++/// Locate the installed `glb_optimize.mjs` (shipped beside the binary by install.sh,+/// or in ~/.local/bin) and run it: `node glb_optimize.mjs <in> <out>`. Returns the+/// optimized bytes, or None if node/script/deps are unavailable or it fails.+fn optimize_glb(glb: &[u8]) -> Option<Vec<u8>> {+    let script = std::env::current_exe().ok()+        .and_then(|p| p.parent().map(|d| d.join("glb_optimize.mjs")))+        .filter(|p| p.exists())+        .or_else(|| {+            let home = std::env::var("HOME").ok()?;+            let p = std::path::PathBuf::from(home).join(".local/bin/glb_optimize.mjs");+            p.exists().then_some(p)+        })?;+    let _ = std::fs::create_dir_all(SCRATCH);+    let id = unique_id();+    let inp = format!("{SCRATCH}/opt-{id}.in.glb");+    let outp = format!("{SCRATCH}/opt-{id}.out.glb");+    std::fs::write(&inp, glb).ok()?;+    let ok = Command::new("node").arg(&script).arg(&inp).arg(&outp)+        .status().map(|s| s.success()).unwrap_or(false);+    let out = if ok { std::fs::read(&outp).ok() } else { None };+    let _ = std::fs::remove_file(&inp);+    let _ = std::fs::remove_file(&outp);+    out.filter(|b| b.len() > 20 && &b[0..4] == b"glTF")+}++/// Guarantee a binary GLB is Z-up and stamped `adomMolecule.upAxis = "z"`.+/// Idempotent: if the stamp is already present the bytes are returned unchanged. If+/// the content is rotated (an `adom_zup` node from the optimizer) but unstamped, only+/// the stamp is added. Otherwise every scene root is reparented under a new node+/// rotated +90° about X (maps +Y → +Z) and the stamp is written. On any parse+/// failure the input is returned untouched. Only the JSON chunk is rewritten; the+/// BIN chunk (and any trailing chunks) are preserved verbatim.+fn ensure_zup_stamp(glb: &[u8]) -> Vec<u8> {+    let orig = glb.to_vec();+    if glb.len() < 20 || &glb[0..4] != b"glTF" {+        return orig;+    }+    let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;+    let json_start = 20usize;+    let json_end = match json_start.checked_add(json_len) {+        Some(e) if e <= glb.len() => e,+        _ => return orig,+    };+    // chunk 0 type must be JSON (0x4E4F534A)+    if u32::from_le_bytes([glb[16], glb[17], glb[18], glb[19]]) != 0x4E4F_534A {+        return orig;+    }+    let mut j: Value = match serde_json::from_slice(&glb[json_start..json_end]) {+        Ok(v) => v,+        Err(_) => return orig,+    };+    let already_stamped = j.get("asset").and_then(|a| a.get("extras"))+        .and_then(|e| e.get("adomMolecule")).and_then(|m| m.get("upAxis"))+        .and_then(|u| u.as_str()).map(|s| s.eq_ignore_ascii_case("z")).unwrap_or(false);+    if already_stamped {+        return orig;+    }+    let obj = match j.as_object_mut() {+        Some(o) => o,+        None => return orig,+    };+    // Rotate to Z-up unless already rotated (optimizer left an `adom_zup` node).+    let has_zup = obj.get("nodes").and_then(|n| n.as_array())+        .map(|a| a.iter().any(|n| n.get("name").and_then(|s| s.as_str()) == Some("adom_zup")))+        .unwrap_or(false);+    if !has_zup {+        // Plan the reparenting from an immutable read (one +90°X wrapper per scene),+        // then apply it — never holding two mutable sub-borrows of `obj` at once.+        let scenes_roots = j_scenes_roots(obj);+        let mut nodes_len = obj.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);+        let mut new_nodes: Vec<Value> = Vec::new();+        let mut scene_updates: Vec<(usize, usize)> = Vec::new();+        for (scene_idx, roots) in scenes_roots {+            if roots.is_empty() { continue; }+            let new_idx = nodes_len;+            new_nodes.push(json!({+                "name": "adom_zup",+                "rotation": [0.707_106_78_f64, 0.0, 0.0, 0.707_106_78_f64],+                "children": roots,+            }));+            scene_updates.push((scene_idx, new_idx));+            nodes_len += 1;+        }+        if !new_nodes.is_empty() {+            if let Some(nodes) = obj.entry("nodes").or_insert_with(|| json!([])).as_array_mut() {+                nodes.extend(new_nodes);+            }+            if let Some(scenes) = obj.get_mut("scenes").and_then(|s| s.as_array_mut()) {+                for (si, ni) in scene_updates {+                    if let Some(sc) = scenes.get_mut(si).and_then(|s| s.as_object_mut()) {+                        sc.insert("nodes".into(), json!([ni]));+                    }+                }+            }+        }+    }+    // stamp asset.extras.adomMolecule.upAxis = "z"+    let asset = obj.entry("asset").or_insert_with(|| json!({}));+    let extras = asset.as_object_mut().unwrap().entry("extras").or_insert_with(|| json!({}));+    let am = extras.as_object_mut().unwrap().entry("adomMolecule").or_insert_with(|| json!({}));+    if let Some(m) = am.as_object_mut() { m.insert("upAxis".into(), json!("z")); }++    rebuild_glb(glb, &j).unwrap_or(orig)+}++/// (scene_index, root node-index list) for every scene, read before mutation.+fn j_scenes_roots(obj: &serde_json::Map<String, Value>) -> Vec<(usize, Vec<Value>)> {+    obj.get("scenes").and_then(|s| s.as_array()).map(|scenes| {+        scenes.iter().enumerate().map(|(i, sc)| {+            let roots = sc.get("nodes").and_then(|n| n.as_array()).cloned().unwrap_or_default();+            (i, roots)+        }).collect()+    }).unwrap_or_default()+}++/// Re-emit a binary GLB with a replaced JSON chunk, preserving all following chunks+/// (BIN etc.) verbatim. JSON is padded with spaces, binary chunks with zeros, each to+/// a 4-byte boundary — per the glTF binary spec.+fn rebuild_glb(orig: &[u8], json: &Value) -> Option<Vec<u8>> {+    let old_json_len = u32::from_le_bytes([orig[12], orig[13], orig[14], orig[15]]) as usize;+    let after_json = 20usize.checked_add(old_json_len)?;+    let trailing = orig.get(after_json..).unwrap_or(&[]);+    let mut js = serde_json::to_vec(json).ok()?;+    while js.len() % 4 != 0 { js.push(b' '); }+    let total = 12 + 8 + js.len() + trailing.len();+    let mut out = Vec::with_capacity(total);+    out.extend_from_slice(b"glTF");+    out.extend_from_slice(&2u32.to_le_bytes());+    out.extend_from_slice(&(total as u32).to_le_bytes());+    out.extend_from_slice(&(js.len() as u32).to_le_bytes());+    out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes()); // "JSON"+    out.extend_from_slice(&js);+    out.extend_from_slice(trailing);+    Some(out)+}+ /// Split a request path into (path_without_query, the `?v=<version>` value if present). /// Used by the board asset routes to serve a past version from `render/<version>/`. fn split_ver(p: &str) -> (&str, Option<String>) {@@ -2602,3 +2790,150 @@ fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {  const INDEX_HTML: &str = include_str!("manage.html"); const LOADER_GIF: &[u8] = include_bytes!("loader.gif");++#[cfg(test)]+mod glb_tests {+    use super::*;++    /// Assemble a minimal binary GLB from a glTF JSON value + BIN payload.+    fn build_glb(json: &Value, bin: &[u8]) -> Vec<u8> {+        let mut js = serde_json::to_vec(json).unwrap();+        while js.len() % 4 != 0 { js.push(b' '); }+        let mut bn = bin.to_vec();+        while bn.len() % 4 != 0 { bn.push(0); }+        let total = 12 + 8 + js.len() + 8 + bn.len();+        let mut out = Vec::new();+        out.extend_from_slice(b"glTF");+        out.extend_from_slice(&2u32.to_le_bytes());+        out.extend_from_slice(&(total as u32).to_le_bytes());+        out.extend_from_slice(&(js.len() as u32).to_le_bytes());+        out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes());+        out.extend_from_slice(&js);+        out.extend_from_slice(&(bn.len() as u32).to_le_bytes());+        out.extend_from_slice(&0x004E_4942u32.to_le_bytes());+        out.extend_from_slice(&bn);+        out+    }++    fn json_of(glb: &[u8]) -> Value {+        let jlen = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;+        serde_json::from_slice(&glb[20..20 + jlen]).unwrap()+    }++    // A Y-up scene: one root node (index 0) bearing a mesh.+    fn yup_glb(bin: &[u8]) -> Vec<u8> {+        let j = json!({+            "asset": {"version": "2.0"},+            "scenes": [{"nodes": [0]}],+            "scene": 0,+            "nodes": [{"mesh": 0, "name": "board"}],+            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],+        });+        build_glb(&j, bin)+    }++    #[test]+    fn stamps_and_rotates_raw_yup() {+        let bin = b"BINARYDATA-1234567890";+        let out = ensure_zup_stamp(&yup_glb(bin));+        // stamp present + readable by the same accessor APM uses+        assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));+        let j = json_of(&out);+        let nodes = j["nodes"].as_array().unwrap();+        // exactly one adom_zup wrapper, rotating +90° about X, wrapping the old root+        let zup: Vec<_> = nodes.iter().filter(|n| n["name"] == "adom_zup").collect();+        assert_eq!(zup.len(), 1, "one wrapper node");+        let rot = zup[0]["rotation"].as_array().unwrap();+        assert!((rot[0].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);+        assert!((rot[3].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);+        assert_eq!(zup[0]["children"], json!([0]));+        // the scene now points at the wrapper, not the bare root+        let widx = nodes.iter().position(|n| n["name"] == "adom_zup").unwrap();+        assert_eq!(j["scenes"][0]["nodes"], json!([widx]));+        // BIN chunk survives verbatim+        assert!(out.windows(bin.len()).any(|w| w == bin), "bin preserved");+    }++    #[test]+    fn is_idempotent() {+        let once = ensure_zup_stamp(&yup_glb(b"payload-xyz"));+        let twice = ensure_zup_stamp(&once);+        assert_eq!(once, twice, "already-stamped input returned byte-identical");+    }++    #[test]+    fn rotated_but_unstamped_only_gets_stamp() {+        // Mimics the optimizer having rotated (adom_zup) but not stamped.+        let j = json!({+            "asset": {"version": "2.0"},+            "scenes": [{"nodes": [1]}],+            "scene": 0,+            "nodes": [+                {"mesh": 0, "name": "board"},+                {"name": "adom_zup", "rotation": [0.70710678, 0.0, 0.0, 0.70710678], "children": [0]}+            ],+            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],+        });+        let out = ensure_zup_stamp(&build_glb(&j, b"bin"));+        assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));+        let j2 = json_of(&out);+        let zup = j2["nodes"].as_array().unwrap().iter().filter(|n| n["name"] == "adom_zup").count();+        assert_eq!(zup, 1, "no second rotation node added");+    }++    #[test]+    fn non_glb_passes_through() {+        let junk = b"not a glb at all".to_vec();+        assert_eq!(ensure_zup_stamp(&junk), junk);+    }++    fn obj(v: Value) -> serde_json::Map<String, Value> { v.as_object().unwrap().clone() }++    #[test]+    fn manifest_preserves_unknown_toplevel_keys() {+        // The exact regression from #218: a re-render must not drop `viewers`.+        let existing = obj(json!({+            "v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],+            "viewers": ["layout", "sch-usb"],+            "viewers_path": "viewers/{version}/",+            "versions": [{"version": "1.3.0", "hash": "aaa", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout"]}],+        }));+        // an unchanged re-render (same hash) still must not clobber unknown keys+        let m = merge_render_manifest(&existing, "1.3.0", "aaa", false, true, true, &["Root".into()]);+        assert_eq!(m["viewers"], json!(["layout", "sch-usb"]));+        assert_eq!(m["viewers_path"], json!("viewers/{version}/"));+        assert_eq!(m["versions"][0]["viewers"], json!(["layout"]), "per-version key survives");+    }++    #[test]+    fn manifest_new_version_merges_not_replaces() {+        // A changed render for a version string that already carries extra keys must+        // update owned keys yet keep the unknown ones on that entry.+        let existing = obj(json!({+            "v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],+            "viewers": ["layout"],+            "versions": [{"version": "1.4.0", "hash": "old", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout", "sch"]}],+        }));+        let m = merge_render_manifest(&existing, "1.4.0", "new", true, true, false, &["Root".into(), "Pwr".into()]);+        let v0 = &m["versions"][0];+        assert_eq!(v0["version"], json!("1.4.0"));+        assert_eq!(v0["hash"], json!("new"), "owned key updated");+        assert_eq!(v0["pcb"], json!(false), "owned key updated");+        assert_eq!(v0["sheets"], json!(["Root", "Pwr"]));+        assert_eq!(v0["viewers"], json!(["layout", "sch"]), "unknown per-version key preserved");+        assert_eq!(m["current"], json!("1.4.0"));+        assert_eq!(m["viewers"], json!(["layout"]), "top-level unknown key preserved");+        // exactly one entry for 1.4.0 (merged, not duplicated)+        let count = m["versions"].as_array().unwrap().iter()+            .filter(|v| v["version"] == json!("1.4.0")).count();+        assert_eq!(count, 1);+    }++    #[test]+    fn manifest_first_archive_from_empty() {+        let m = merge_render_manifest(&serde_json::Map::new(), "1.0.0", "h0", true, true, true, &["Root".into()]);+        assert_eq!(m["v"], json!(2));+        assert_eq!(m["current"], json!("1.0.0"));+        assert_eq!(m["versions"][0]["hash"], json!("h0"));+    }+}