app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← Commit history
Fix #218: Z-up + upAxis-stamp board GLB (finalize_board_glb); merge-safe render manifest (merge_render_manifest)
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"));+ }+}