← Commit history

0.2.48 source (not yet released, needs signing): list_library and the show_* surfaces refuse a document under an unread argument instead of opening the demo, demoFallback in every reply (#20); schematic_data refuses a SchLib as the current sheet and takes filePath (#21); build_board into a project that already lists the board reopens the project so the board has one entry and one view (#23); read_symbol returns model links (#24); release.py checks and pushes page.json and main.rs with the other stamps (#22). Live-tested on DESKTOP-BAR6S0F, Altium Designer 26.11.1; 56 tests, lint 146 verbs.

Colby Knox ·5aeb19021c ·5d ago ·parent 9999c7f
10 files changed +179−26
bridge-rs/src/main.rs+1−1
@@ -31,7 +31,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.47";+pub const BRIDGE_VERSION: &str = "0.2.48";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/routing.rs+64−1
@@ -792,6 +792,53 @@ fn outline_spec(o: &Value) -> Result<String, Value> { }  /// altium_build_board {pcbPath, libraryPath, outline, parts:[{ref, footprint, x, y, rotation, comment}], nets:{net:["R1.1",...]}}+/// The documents a .PrjPcb lists (`DocumentPath=` lines), resolved against the project's folder.+pub fn listed_documents(prj_text: &str, prj_dir: &std::path::Path) -> Vec<std::path::PathBuf> {+    prj_text.lines().filter_map(|l| l.trim().strip_prefix("DocumentPath=")).map(|d| {+        let d = d.trim();+        let pth = std::path::Path::new(d);+        if pth.is_absolute() || d.contains(':') { pth.to_path_buf() } else { prj_dir.join(d) }+    }).collect()+}++fn same_file_name(a: &std::path::Path, b: &std::path::Path) -> bool {+    match (a.file_name(), b.file_name()) {+        (Some(x), Some(y)) => x.to_string_lossy().eq_ignore_ascii_case(&y.to_string_lossy()),+        _ => false,+    }+}++/// A board built into a folder whose project already lists it (altium_build_schematic writes the+/// project first) is created as a new document that joins the project and is then renamed onto the+/// listed path: Altium holds the file twice, one entry greyed, and a second hidden view survives a+/// project close (#23). Save the project's sheets, boards and libraries, close them and the project,+/// reopen the project and show the board: one entry, one view. None when no project lists the board.+fn reopen_listing_project(pcb: &str) -> Option<String> {+    let pcb_p = std::path::Path::new(pcb);+    let dir = pcb_p.parent()?;+    let (prj, docs) = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path())+        .filter(|p| p.extension().map(|x| x.eq_ignore_ascii_case("prjpcb")).unwrap_or(false))+        .find_map(|prj| {+            let text = String::from_utf8_lossy(&std::fs::read(&prj).ok()?).to_string();+            let docs = listed_documents(&text, dir);+            docs.iter().any(|d| same_file_name(d, pcb_p)).then_some((prj, docs))+        })?;+    const KINDS: [&str; 4] = ["schdoc", "pcbdoc", "schlib", "pcblib"];+    let docs: Vec<String> = docs.iter()+        .filter(|d| d.extension().map(|x| KINDS.contains(&x.to_string_lossy().to_lowercase().as_str())).unwrap_or(false))+        .map(|d| d.display().to_string()).collect();+    for d in &docs {+        let _ = crate::surfaces::save(&json!({"filePath": d}));+    }+    for d in docs.iter().chain(std::iter::once(&prj.display().to_string())) {+        let _ = agent::call("close_document", &[("path", d.clone())], None, Duration::from_secs(30));+    }+    let prj_s = prj.display().to_string();+    let _ = crate::support::agent_open(&prj_s, 90);+    let _ = crate::support::agent_open(pcb, 90);+    Some(prj_s)+}+ pub fn build_board(args: &Value) -> Value {     let pcb = args.get("pcbPath").and_then(|v| v.as_str()).unwrap_or("");     let lib = args.get("libraryPath").and_then(|v| v.as_str()).unwrap_or("");@@ -835,7 +882,8 @@ pub fn build_board(args: &Value) -> Value {     let placed_refs: Vec<String> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();     let missing: Vec<&str> = parts.iter().filter_map(|p| p["ref"].as_str()).filter(|r| !placed_refs.iter().any(|x| x == r)).collect();     let clean = wrong.is_empty() && missing.is_empty();-    json!({"success": clean, "errorCode": if clean { Value::Null } else { json!("readback_mismatch") }, "pcb": pcb, "placed": d["placed"], "padsWired": d["padsWired"],+    let reopened = reopen_listing_project(pcb);+    json!({"success": clean, "errorCode": if clean { Value::Null } else { json!("readback_mismatch") }, "pcb": pcb, "projectReopened": reopened, "placed": d["placed"], "padsWired": d["padsWired"],            "outlineVertices": d["outlineVertices"], "missingParts": missing, "wrongPins": wrong, "revision": st["revision"], "unconnected": st["unconnected"],            "counts": st["counts"],            "_hint": if clean { "Saved and read back: every part is on the board and every listed pin on its net. altium_routing_state reads it; unconnected is the ratsnest to route." } else { "Some pins did not land on their net (wrongPins) or parts are missing: a footprint's pad names must match the netlist's pin numbers." }})@@ -1221,3 +1269,18 @@ pub fn apply_silkscreen(args: &Value) -> Value {     json!({"success": d["failed"].as_i64() == Some(0), "applied": d["applied"], "failed": d["failed"], "results": d["results"],            "after": after, "_hint": "after is silk_state once it landed: check each label's box (Altium's own measure) against pads, vias, bodies and the other labels."}) }++#[cfg(test)]+mod project_listing_tests {+    use super::*;++    #[test]+    fn listed_documents_resolve_against_the_project_folder() {+        let text = "[Design]\r\n[Document1]\r\nDocumentPath=Buck.SchDoc\r\n[Document2]\r\nDocumentPath=Buck.PcbDoc\r\n[Document3]\r\nDocumentPath=library\\Buck.SchLib\r\n[Document4]\r\nDocumentPath=C:\\other\\Shared.PcbLib\r\n";+        let docs = listed_documents(text, std::path::Path::new("/w"));+        assert_eq!(docs.len(), 4);+        assert!(same_file_name(&docs[1], std::path::Path::new("/elsewhere/BUCK.pcbdoc")));+        assert_eq!(docs[0], std::path::Path::new("/w").join("Buck.SchDoc"));+        assert_eq!(docs[3], std::path::PathBuf::from("C:\\other\\Shared.PcbLib"));+    }+}
bridge-rs/src/surfaces.rs+67−6
@@ -7,6 +7,30 @@ use crate::{agent, dialogs, instance, shot}; use serde_json::{json, Value}; use std::time::Duration; +/// An Altium document path given under an argument this verb does not read (`path` for+/// `libraryPath`, say) used to fall through to the bundled demo, which then opened in the+/// user's Altium and answered for the wrong file (adom/altium-bridge#20). Refuse instead.+pub fn stray_path(args: &Value, reads: &[&str]) -> Option<Value> {+    const EXT: [&str; 8] = [".schlib", ".pcblib", ".schdoc", ".pcbdoc", ".prjpcb", ".intlib", ".outjob", ".prjscr"];+    let obj = args.as_object()?;+    for (k, v) in obj {+        let Some(t) = v.as_str() else { continue };+        let low = t.to_lowercase();+        if !reads.contains(&k.as_str()) && EXT.iter().any(|e| low.ends_with(e)) {+            return Some(json!({"success": false, "errorCode": "unknown_argument",+                "error": format!("'{k}' is not an argument of this verb, so {t} would have been ignored and the bundled demo opened instead"),+                "_hint": format!("Pass the document as {}.", reads.iter().map(|r| format!("'{r}'")).collect::<Vec<_>>().join(" or "))}));+        }+    }+    None+}++/// Tag a reply that answered for the bundled demo document, so a caller never mistakes it for its own.+fn mark_demo(mut out: Value, used_demo: bool) -> Value {+    out["demoFallback"] = json!(used_demo);+    out+}+ /// Open a document through the agent, run follow-up processes, return the frame. pub fn open_then(path: &str, commands: &[(&str, &str)], timeout: Duration, label: &str) -> Value {     if let Some(g) = support::need_licensed() {@@ -47,7 +71,11 @@ pub fn lib_surface(args: &Value, kind: &str, default_path: &str, label: &str, af     if let Some(g) = support::need_licensed() {         return g;     }+    if let Some(e) = stray_path(args, &["libraryPath", "filePath"]) {+        return e;+    }     let t0 = support::now();+    let demo = s(args, "libraryPath").or_else(|| s(args, "filePath")).is_none();     let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(|| default_path.to_string());     let name = s(args, "symbolName").or_else(|| s(args, "footprintName")).or_else(|| s(args, "name")).unwrap_or_default();     let timeout = timeout_of(args, 60);@@ -80,7 +108,7 @@ pub fn lib_surface(args: &Value, kind: &str, default_path: &str, label: &str, af     let mw = instance::main_window().unwrap_or(json!({}));     let no_proof = frame.get("proofUnavailable").and_then(|b| b.as_bool()) == Some(true);     let selected = sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null);-    let mut out = json!({"success": dl.is_empty() && !no_proof, "surface": label, "path": path,+    let mut out = json!({"success": dl.is_empty() && !no_proof, "surface": label, "path": path, "demoFallback": demo,         "selected": selected,         "steps": steps, "title": mw.get("title"), "dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,         "windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),@@ -105,23 +133,31 @@ fn out_str(v: &Value) -> String { // ---- the eight surfaces ------------------------------------------------------  pub fn show_project(args: &Value) -> Value {+    if let Some(e) = stray_path(args, &["filePath"]) { return e; }+    let demo = s(args, "filePath").is_none();     let p = s(args, "filePath").unwrap_or_else(support::demo_project);-    open_then(&p, &[], timeout_of(args, 60), "project")+    mark_demo(open_then(&p, &[], timeout_of(args, 60), "project"), demo) }  pub fn show_schematic(args: &Value) -> Value {+    if let Some(e) = stray_path(args, &["filePath"]) { return e; }+    let demo = s(args, "filePath").is_none();     let p = s(args, "filePath").unwrap_or_else(support::demo_sch);-    open_then(&p, &[("Sch:Zoom", "Action=All")], timeout_of(args, 60), "schematic")+    mark_demo(open_then(&p, &[("Sch:Zoom", "Action=All")], timeout_of(args, 60), "schematic"), demo) }  pub fn show_2d_board(args: &Value) -> Value {+    if let Some(e) = stray_path(args, &["filePath"]) { return e; }+    let demo = s(args, "filePath").is_none();     let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);-    open_then(&p, &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "2d_board")+    mark_demo(open_then(&p, &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "2d_board"), demo) }  pub fn show_3d_board(args: &Value) -> Value {+    if let Some(e) = stray_path(args, &["filePath"]) { return e; }+    let demo = s(args, "filePath").is_none();     let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);-    open_then(&p, &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "3d_board")+    mark_demo(open_then(&p, &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "3d_board"), demo) }  pub fn show_symbol(args: &Value) -> Value {@@ -154,6 +190,10 @@ fn lib_kind_and_path(args: &Value) -> (String, String) { }  pub fn show_library(args: &Value) -> Value {+    if let Some(e) = stray_path(args, &["libraryPath", "filePath"]) {+        return e;+    }+    let demo = s(args, "libraryPath").or_else(|| s(args, "filePath")).is_none();     let (kind, path) = lib_kind_and_path(args);     let mut a = args.clone();     a["libraryPath"] = json!(path);@@ -164,10 +204,15 @@ pub fn show_library(args: &Value) -> Value {         r["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);         r["componentCount"] = lst.get("data").and_then(|d| d.get("count")).cloned().unwrap_or(Value::Null);     }+    r["demoFallback"] = json!(demo);     r }  pub fn list_library(args: &Value) -> Value {+    if let Some(e) = stray_path(args, &["libraryPath", "filePath"]) {+        return e;+    }+    let demo = s(args, "libraryPath").or_else(|| s(args, "filePath")).is_none();     let (kind, path) = lib_kind_and_path(args);     if let Some(g) = support::need_licensed() {         return g;@@ -180,7 +225,7 @@ pub fn list_library(args: &Value) -> Value {     let mut lst = agent::call("lib_list", &[("kind", kind.clone()), ("limit", limit)], None, Duration::from_secs(60));     lst["path"] = json!(path);     lst["kind"] = json!(kind);-    lst+    mark_demo(lst, demo) }  // ---- window / document verbs -------------------------------------------------@@ -632,3 +677,19 @@ pub fn demo_live(args: &Value) -> Value {         "nextCall": {"verb": "altium_demo", "args": {"live": true, "stage": next_stage}},         "_hint": "SYNC MODEL (best): pre-cache the whole narration once with `adom-tts load` (one marker per stop), then per stop fire `adom-tts cue <id> <n> --confirm` in the background and IMMEDIATELY make nextCall — the prefetched cue starts instantly and this verb's in-bridge choreography (~5-17s) runs under the voice; advance when BOTH finish. Fallback without 1.9.47: say --play detached per stop. On the last call (done:true) restore the user's own document."}) }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn stray_path_refuses_a_document_under_an_unread_argument() {+        let e = stray_path(&json!({"path": "C:/x/Lib.SchLib"}), &["libraryPath", "filePath"]).expect("refused");+        assert_eq!(e["errorCode"], "unknown_argument");+        assert!(e["_hint"].as_str().unwrap().contains("libraryPath"));+        assert!(stray_path(&json!({"libraryPath": "C:/x/Lib.SchLib", "symbolName": "R1"}), &["libraryPath", "filePath"]).is_none());+        assert!(stray_path(&json!({}), &["filePath"]).is_none());+        assert!(stray_path(&json!({"reason": "show it", "timeout": 30}), &["filePath"]).is_none());+        assert!(stray_path(&json!({"board": "C:/x/B.PcbDoc"}), &["filePath"]).is_some());+    }+}
bridge-rs/src/verbs.rs+16−7
@@ -497,11 +497,11 @@ fn describe() -> Value {             json!({"data": "{designator, pattern, count, otherPrimitives, primitives: [{kind: track|arc|pad|text, layerId, ...}]}", "roundtripMs": "int"}), 120,             "Use it to verify an imported footprint on a altium_test_board: pad layerId 74 = multi-layer (plated through), which is what altium_plate_vias produces.",             &["component_pins", "plate_vias", "test_board", "pcb_report"], &["Needs a .PcbDoc current: it reads a placed component, not a PcbLib member (altium_pcb_report reads libraries).", "3D bodies, fills and regions only show up in otherPrimitives."], json!({"designator": "U1"})),-        verb_doc("schematic_data", "Components on the CURRENT schematic sheet with their pins (number, name, electrical type), position and orientation. designator filters to one part.",-            json!({"designator": "string - optional filter", "limit": "int, default 500", "pins": "bool, default true", "params": "bool, default false - include each component's parameters"}),+        verb_doc("schematic_data", "Components on a schematic sheet (filePath, else the CURRENT sheet) with their pins (number, name, electrical type), position and orientation. designator filters to one part.",+            json!({"filePath": "path to the .SchDoc - optional, made current first (alias sheet)", "designator": "string - optional filter", "limit": "int, default 500", "pins": "bool, default true", "params": "bool, default false - include each component's parameters"}),             json!({"data": "{document, count, components: [{designator, partId, libReference, comment, xMil, yMil, orientation, pins: [{number, name, electrical}], params: []}]}", "roundtripMs": "int"}), 120,             "Pin-level view of a sheet. params[] is empty unless you pass params:true; altium_read_schematic is the fuller parameter view.",-            &["read_schematic", "cross_check", "show_schematic"], &["Needs a .SchDoc current; one sheet only.", "electrical is Altium's pin-type ordinal, not a name."], json!({"designator": "U1"})),+            &["read_schematic", "cross_check", "show_schematic"], &["One sheet per call. Without filePath the current document must be a .SchDoc: a schematic library is refused (no_current_schematic), not read as an empty sheet.", "electrical is Altium's pin-type ordinal, not a name."], json!({"designator": "U1"})),         // ---- surfaces ----         verb_doc("show_project", "Open a project (.PrjPcb) through the agent and return a proof frame of Altium's main window. With no filePath it opens Altium's bundled example project (Bluetooth Sentinel).",             json!({"filePath": "path to a .PrjPcb - default: the bundled Bluetooth Sentinel example", "timeout": "seconds, default 60"}),@@ -718,11 +718,11 @@ fn describe() -> Value {             "altium_save afterwards; altium_show_symbol to see where the text landed.",             &["set_params", "read_symbol", "show_symbol"], &["'parameter not found' = the name must match a parameter altium_read_symbol lists.", "Visible parameters stack over the symbol body: show few."],             json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX", "param": "Manufacturer Part Number", "visible": true})),-        verb_doc("read_symbol", "Read a SchLib component back: description, display-mode count, the pins of mode 0 (designator, name) and every parameter with its hidden flag.",+        verb_doc("read_symbol", "Read a SchLib component back: description, display-mode count, the pins of mode 0 (designator, name), every parameter with its hidden flag, and its model links (footprints, 3D, sim).",             json!({"libraryPath": "path to a .SchLib - optional; opened first", "symbolName": "string", "timeout": "seconds, default 90"}),-            json!({"data": "{libref, description, displayModeCount, pinsMode0: int, pins: [{des, name}], params: [{name, value, hidden}]}", "roundtripMs": "int"}), 90,-            "The verification step after altium_import_part / altium_set_params. For the drawing and the footprint models use altium_symbol_prims.",-            &["symbol_prims", "set_params", "list_library", "show_symbol"], &["Footprint models are NOT reported here (not a missing link): altium_symbol_prims {models:true}.", "Only mode 0 pins are listed."],+            json!({"data": "{libref, description, displayModeCount, pinsMode0: int, pins: [{des, name}], params: [{name, value, hidden}], models: [{type, name, isCurrent}]}", "roundtripMs": "int"}), 90,+            "The verification step after altium_import_part / altium_set_params: models[] shows which footprint the symbol is bound to (type PCBLIB, isCurrent true for the one a placed part uses). For the drawing use altium_symbol_prims.",+            &["symbol_prims", "set_params", "list_library", "show_symbol"], &["The SCH Library panel's Model pane can lag behind altium_show_symbol; models[] here is the symbol's own record.", "Only mode 0 pins are listed."],             json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX"})),         verb_doc("clone_symbol", "Copy a symbol inside the current SchLib under a new name, all display modes included.",             json!({"libraryPath": "path to a .SchLib - optional; opened first", "from": "string - existing LibReference", "to": "string - new LibReference", "timeout": "seconds, default 90"}),@@ -1210,6 +1210,15 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->             Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120),         },         "schematic_data" => {+            // a named sheet becomes the current document first; without one the verb reads the current sheet+            // and refuses a library (#21)+            if let Some(e) = surfaces::stray_path(args, &["filePath", "sheet"]) { return e; }+            if let Some(p) = s(args, "filePath").or_else(|| s(args, "sheet")) {+                if !p.to_lowercase().ends_with(".schdoc") {+                    return json!({"success": false, "errorCode": "not_a_sheet", "error": format!("{p} is not a .SchDoc"), "_hint": "schematic_data reads a schematic sheet; a .SchLib is read with altium_read_symbol / altium_list_library."});+                }+                if let Some(e) = support::must_open(&p, 60) { return e; }+            }             let pins = if args.get("pins").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" };             // The agent has always honoured params=1; the dispatch never sent it, so every reply             // carried an empty params[] and the doc had to apologise for it. Off by default:
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.47",+  "version": "0.2.48",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Altium - the Altium Bridge",   "brief": "Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.",-  "version": "0.2.47",+  "version": "0.2.48",   "hero": {     "type": "image",     "path": "screenshots/hero.png"
scripts/release.py+7−4
@@ -30,7 +30,7 @@ Rules (insiders-tier-publish, verbatim where it matters): every publish is insid on a human's words; an AI never promotes on its own judgment; one plain semver per build in both tiers; every insiders publish appends its release-log entry before it is called done. -Versions live in ONE place: src/bridge.json (BRIDGE_VERSION, main.rs, package.json are checked).+Versions live in ONE place: src/bridge.json (BRIDGE_VERSION, main.rs, package.json, page.json are checked). """ import datetime import hashlib@@ -124,10 +124,13 @@ def lint_verbs():   def lint_versions(ver):-    """One version, three stamps beside bridge.json — refuse a half-bumped release."""+    """One version, four stamps beside bridge.json — refuse a half-bumped release. page.json is the+    wiki page's own version (0.2.47 shipped with it at 0.2.46); main.rs is what the exe reports+    (0.2.46 shipped reporting 0.2.45, adom/altium-bridge#22)."""     stamps = {         "src/BRIDGE_VERSION": (SRC / "BRIDGE_VERSION").read_text().strip(),         "package.json": json.loads((ROOT / "package.json").read_text())["version"],+        "page.json": json.loads((ROOT / "page.json").read_text())["version"],     }     m = re.search(r'BRIDGE_VERSION: &str = "([^"]+)"', (RS / "src" / "main.rs").read_text())     stamps["bridge-rs/src/main.rs"] = m.group(1) if m else "?"@@ -446,8 +449,8 @@ def ship_insiders(log_line):      print("== source stamps to the product page (public manifest NOT touched) ==")     run("adom-wiki", "repo", "push", PAGE, "-m", f"v{ver} (insiders): {log_line}",-        "--files", "CHANGELOG.md", "src/bridge.json", "src/BRIDGE_VERSION", "package.json",-        cwd=ROOT)+        "--files", "CHANGELOG.md", "src/bridge.json", "src/BRIDGE_VERSION", "package.json", "page.json",+        "bridge-rs/src/main.rs", cwd=ROOT)      print("== skills pkg in lockstep (release rows share the version namespace, adom/wiki#170) ==")     run("adom-wiki", "pkg", "publish", "--org", "adom", cwd=ROOT)
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.47+0.2.48
src/agent/AdomAgent.pas.tmpl+20−3
@@ -3892,7 +3892,8 @@ Var     Prim : ISch_GraphicalObject;     Pin  : ISch_Pin;     Pm   : ISch_Parameter;-    Pins, Params : String;+    Impl : ISch_Implementation;+    Pins, Params, Models : String;     NPins : Integer; Begin     SLib := SchServer.GetCurrentSchDocument;@@ -3932,11 +3933,25 @@ Begin         Prim := It.NextSchObject;     End;     C.SchIterator_Destroy(It);+    { the symbol's model links (footprints, sim, 3D): the eImplementation child iterator, as in+      CmdSymbolPrims (ImplementationCount halts the script on some builds) (#24) }+    Models := '';+    It := C.SchIterator_Create;+    It.AddFilter_ObjectSet(MkSet(eImplementation));+    Impl := It.FirstSchObject;+    While Impl <> Nil Do+    Begin+        If Models <> '' Then Models := Models + ',';+        Models := Models + '{' + JStr('type', Impl.ModelType) + ',' + JStr('name', Impl.ModelName) + ',' ++            JRaw('isCurrent', JBool(Impl.IsCurrent)) + '}';+        Impl := It.NextSchObject;+    End;+    C.SchIterator_Destroy(It);     Result := '{' + JRaw('ok','true') + ',' + JStr('libref', C.LibReference) + ',' +         JStr('description', C.ComponentDescription) + ',' +         JRaw('displayModeCount', IntToStr(C.DisplayModeCount)) + ',' +         JRaw('pinsMode0', IntToStr(NPins)) + ',' +-        JRaw('pins', '[' + Pins + ']') + ',' + JRaw('params', '[' + Params + ']') + '}';+        JRaw('pins', '[' + Pins + ']') + ',' + JRaw('params', '[' + Params + ']') + ',' + JRaw('models', '[' + Models + ']') + '}'; End;  { Copy a footprint inside the current PcbLib under a new name (per-primitive). }@@ -4532,7 +4547,9 @@ Var     WantPins, WantParams : Boolean; Begin     Doc := SchServer.GetCurrentSchDocument;-    If Doc = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current schematic document (open a .SchDoc first)') + '}'; Exit; End;+    If Doc = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_schematic') + ',' + JStr('error','no current schematic document (open a .SchDoc first)') + '}'; Exit; End;+    { a schematic LIBRARY is an ISch_Document too: read as a sheet it answered ok with 0 parts (#21) }+    If Doc.ObjectID = eSchLib Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_schematic') + ',' + JStr('error','the current document is a schematic library, not a sheet: ' + Doc.DocumentName) + '}'; Exit; End;     Want := Param(Lines, 'designator');     Limit := StrToIntDef(Param(Lines, 'limit'), 500);     WantPins := Param(Lines, 'pins') <> '0';
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.47",+  "version": "0.2.48",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",