← Commit history

1.0.33: NATIVE (Rust) build. 1.0.33 (wiki #109): model check and 3D show verbs report the 3D viewer's model-visibility toggles and say when a hidden class exists on the board; new kicad_3d_viewer_settings; failed kicad_close lists dialogsSeen; show verbs wait for the file's title before capturing.

John Lauer ·d3619d8946 ·1mo ago ·parent 7ed92cf
1 file changed +156
rust/crates/kicad-core/src/model_check.rs+156
@@ -631,6 +631,114 @@ pub fn check_footprint(ctx: &Ctx, library: &str, footprint: &str, project_dir: O     } } +/// The 3D viewer's model-visibility toggles (wiki #109): `<config>/3d_viewer.json`,+/// `render.show_footprints_*`. They hide whole CLASSES of footprint bodies in the viewer+/// AND in `kicad-cli pcb render`, so a board whose every model file resolves can still+/// render as pads only. `insert` is KiCad's name for SMD, `normal` for through-hole.+pub const VIEWER_FLAGS: &[(&str, &str, &str)] = &[+    ("smd", "show_footprints_insert", "SMD models"),+    ("throughHole", "show_footprints_normal", "through-hole models"),+    ("virtual", "show_footprints_virtual", "virtual (other) models"),+    ("notInPosFile", "show_footprints_not_in_posfile", "models not in the position file"),+    ("dnp", "show_footprints_dnp", "models marked DNP"),+];++pub fn viewer_settings_path(ctx: &Ctx) -> Option<std::path::PathBuf> {+    ctx.config_dir.as_ref().map(|d| d.join("3d_viewer.json"))+}++/// `{settingsFile, flags:{smd,throughHole,virtual,notInPosFile,dnp}, known}`. A missing+/// file or key reads as KiCad's default (true, except dnp which defaults false).+pub fn viewer_visibility(ctx: &Ctx) -> Value {+    let path = viewer_settings_path(ctx);+    let doc: Value = path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    let mut flags = serde_json::Map::new();+    for (key, json_key, _) in VIEWER_FLAGS {+        let default = *key != "dnp";+        flags.insert((*key).to_string(), json!(doc["render"][*json_key].as_bool().unwrap_or(default)));+    }+    json!({"settingsFile": path.as_ref().map(|p| norm(p)), "known": !doc.is_null(), "flags": flags})+}++/// Which viewer class a footprint's body belongs to, from its `(attr ...)` tokens.+pub fn viewer_classes(attrs: &[String]) -> Vec<&'static str> {+    let has = |t: &str| attrs.iter().any(|a| a == t);+    let mut out = vec![if has("smd") { "smd" } else if has("through_hole") { "throughHole" } else { "virtual" }];+    if has("exclude_from_pos_files") { out.push("notInPosFile"); }+    if has("dnp") { out.push("dnp"); }+    out+}++/// Set the viewer's model-visibility toggles (wiki #109 ask 2). File logic only: the caller+/// refuses while KiCad runs, because KiCad rewrites 3d_viewer.json from memory when it exits.+/// Every other key in the file is preserved; the previous file is kept beside it as+/// `3d_viewer.json.adom.bak`; the write goes through a temp file and a rename.+pub fn set_viewer_visibility(ctx: &Ctx, args: &Value) -> Value {+    let Some(path) = viewer_settings_path(ctx) else {+        return json!({"success": false, "errorCode": "not_installed", "error": "No KiCad config directory detected, so there is no 3d_viewer.json to edit."});+    };+    let before = viewer_visibility(ctx);+    let mut wanted: Vec<(&str, &str, bool)> = Vec::new();+    for (key, json_key, _) in VIEWER_FLAGS {+        match args.get(*key) {+            None | Some(Value::Null) => {}+            Some(Value::Bool(b)) => wanted.push((key, json_key, *b)),+            Some(_) => return json!({"success": false, "errorCode": "bad_arg", "error": format!("{key} must be true or false"), "mutated": false}),+        }+    }+    if wanted.is_empty() {+        return json!({"success": true, "mutated": false, "viewer": before,+            "_hint": "Read only. Pass smd, throughHole, virtual, notInPosFile or dnp (true|false) to change a toggle; KiCad must be closed while the file is written, then reopen the viewer."});+    }+    let mut doc: Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| json!({}));+    if !doc.is_object() {+        doc = json!({});+    }+    if !doc["render"].is_object() {+        doc["render"] = json!({});+    }+    let mut changed = serde_json::Map::new();+    for (key, json_key, value) in &wanted {+        if doc["render"][*json_key].as_bool() != Some(*value) {+            changed.insert((*key).to_string(), json!(value));+        }+        doc["render"][*json_key] = json!(value);+    }+    if changed.is_empty() {+        return json!({"success": true, "mutated": false, "viewer": before, "_hint": "Every requested toggle already had that value; nothing was written."});+    }+    if path.is_file() {+        let _ = std::fs::copy(&path, path.with_extension("json.adom.bak"));+    } else if let Some(dir) = path.parent() {+        let _ = std::fs::create_dir_all(dir);+    }+    let tmp = path.with_extension("json.adom.tmp");+    let body = serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n";+    if let Err(e) = std::fs::write(&tmp, body).and_then(|_| std::fs::rename(&tmp, &path)) {+        let _ = std::fs::remove_file(&tmp);+        return json!({"success": false, "errorCode": "settings_write_failed", "error": format!("cannot write {}: {e}", path.display()), "mutated": false});+    }+    json!({"success": true, "mutated": true, "changed": changed, "before": before["flags"], "viewer": viewer_visibility(ctx),+        "backup": norm(&path.with_extension("json.adom.bak")),+        "_hint": "Written. The 3D viewer and kicad-cli pcb render read this file when they start: reopen the board and its viewer (kicad_show_3d_board) to see the bodies."})+}++/// The sentence for a reply when the viewer hides a class that exists on the board.+pub fn viewer_hint(visibility: &Value, counts: &serde_json::Map<String, Value>) -> Option<String> {+    let mut parts = Vec::new();+    for (key, _, label) in VIEWER_FLAGS {+        let n = counts.get(*key).and_then(Value::as_u64).unwrap_or(0);+        if n > 0 && visibility["flags"][*key] == json!(false) {+            parts.push(format!("{label} are hidden and the board has {n} such footprint(s)"));+        }+    }+    if parts.is_empty() {+        return None;+    }+    Some(format!("The 3D viewer settings hide bodies that exist: {}. They will not render in the viewer or in kicad-cli pcb render even though the model files resolve. kicad_3d_viewer_settings shows and sets these toggles (KiCad must be closed while it writes).", parts.join("; ")))+}++ /// kicad_model_check {boardPath}. pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {     let p = Path::new(board_path);@@ -647,6 +755,7 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {     let project_dir = p.parent().map(norm).unwrap_or_default();     let mut per = Vec::new();     let mut bindings = Vec::new();+    let mut class_counts: serde_json::Map<String, Value> = serde_json::Map::new();     let (mut footprints, mut with_model, mut no_model_expected_n, mut missing, mut unresolved, mut nwarn) = (0u64, 0u64, 0u64, 0u64, 0u64, 0u64);     for fp in tree.find_all("footprint") {         let fp_id = fp.atom_at(1).unwrap_or("?").to_string();@@ -660,6 +769,12 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {         }         let rows = model_rows(fp, ctx, Some(&project_dir));         let a = attrs(fp);+        if !rows.is_empty() {+            for c in viewer_classes(&a) {+                let n = class_counts.get(c).and_then(Value::as_u64).unwrap_or(0) + 1;+                class_counts.insert(c.to_string(), json!(n));+            }+        }         bindings.push(json!({             "itemId": fp.find("uuid").and_then(|x| x.atom_at(1)),             "reference": reference,@@ -690,6 +805,8 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {             per.push(json!({"reference": reference, "footprint": fp_id, "warnings": w, "models": models}));         }     }+    let viewer = viewer_visibility(ctx);+    let viewer_note = viewer_hint(&viewer, &class_counts);     json!({         "success": true,         "board": norm(p),@@ -699,6 +816,9 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {                    "missingModel": missing, "unresolvedModel": unresolved, "warnings": nwarn},         "footprintsWithWarnings": per,         "modelBindings": bindings,+        "viewer": viewer,+        "footprintsWithModelByViewerClass": class_counts,+        "_viewerHint": viewer_note,         "bindingsComplete": true,         "evidenceSource": "saved-board-file",         "unsavedEditorStateIncluded": false,@@ -738,6 +858,42 @@ pub fn handle(ctx: &Ctx, args: &Value) -> Value { mod tests {     use super::*; +    // Wiki #109: every model resolved and the chips were still invisible, because the+    // viewer's own "SMD Models" toggle was off.+    #[test]+    fn viewer_toggles_are_read_classed_hinted_and_set() {+        let dir = std::env::temp_dir().join(format!("kb109-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        std::fs::write(dir.join("3d_viewer.json"), r#"{"camera":{"keep":1},"render":{"show_footprints_insert":false,"show_zones":true}}"#).unwrap();+        let ctx = Ctx { config_dir: Some(dir.clone()), ..Default::default() };+        let v = viewer_visibility(&ctx);+        assert_eq!(v["flags"]["smd"], json!(false));+        assert_eq!(v["flags"]["throughHole"], json!(true), "a missing key reads as KiCad's default");+        assert_eq!(v["flags"]["dnp"], json!(false));+        assert_eq!(viewer_classes(&["smd".into(), "dnp".into()]), vec!["smd", "dnp"]);+        assert_eq!(viewer_classes(&["through_hole".into()]), vec!["throughHole"]);+        assert_eq!(viewer_classes(&[]), vec!["virtual"]);+        let mut counts = serde_json::Map::new();+        counts.insert("smd".into(), json!(3));+        counts.insert("throughHole".into(), json!(2));+        let hint = viewer_hint(&v, &counts).unwrap();+        assert!(hint.contains("SMD models are hidden") && hint.contains("3 such"), "{hint}");+        assert!(!hint.contains("through-hole"), "a visible class is not mentioned: {hint}");+        // Read-only call, then a real set that preserves every other key and leaves a backup.+        assert_eq!(set_viewer_visibility(&ctx, &json!({}))["mutated"], json!(false));+        assert_eq!(set_viewer_visibility(&ctx, &json!({"smd": "yes"}))["errorCode"], json!("bad_arg"));+        let r = set_viewer_visibility(&ctx, &json!({"smd": true}));+        assert_eq!(r["mutated"], json!(true), "{r}");+        assert_eq!(r["viewer"]["flags"]["smd"], json!(true));+        let doc: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("3d_viewer.json")).unwrap()).unwrap();+        assert_eq!(doc["camera"]["keep"], json!(1));+        assert_eq!(doc["render"]["show_zones"], json!(true));+        assert!(dir.join("3d_viewer.json.adom.bak").is_file());+        assert!(viewer_hint(&viewer_visibility(&ctx), &counts).is_none());+        assert_eq!(set_viewer_visibility(&ctx, &json!({"smd": true}))["mutated"], json!(false), "idempotent");+        let _ = std::fs::remove_dir_all(&dir);+    }+     const STEP: &[u8] = b"ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n#1=MANIFOLD_SOLID_BREP('x',#2);\n#2=CLOSED_SHELL('',());\nENDSEC;\nEND-ISO-10303-21;\n";      struct Fixture {