app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
//! "Send to" delivery — push the current symbol to the user's desktop EDA tools
//! via the adom-desktop bridge. KiCad takes the native `.kicad_sym` directly
//! (symbol-only); Fusion 360 needs a true EAGLE `.lbr`, which we build with
//! adom-lbr first. Best-effort: a clear message when the bridge is offline.
use serde_json::{json, Value};
use std::process::Command;
/// Run an adom-desktop verb with a JSON payload; return its parsed JSON (or a
/// wrapped raw result). Errors if adom-desktop itself can't be spawned.
fn ad(verb: &str, payload: Value) -> Result<Value, String> {
let mut cmd = Command::new("adom-desktop");
// When the relay has >1 client (e.g. laptop + a cloud VM), a bare verb is
// ambiguous. Honor ADOM_DESKTOP_TARGET to pin the host (same convention the
// rest of the Adom toolchain uses); single-client setups need nothing.
if let Ok(t) = std::env::var("ADOM_DESKTOP_TARGET") {
if !t.is_empty() {
cmd.args(["--target", &t]);
}
}
let out = cmd
.args([verb, &payload.to_string()])
.output()
.map_err(|e| format!("adom-desktop unreachable: {e}"))?;
let txt = String::from_utf8_lossy(&out.stdout).to_string();
Ok(serde_json::from_str::<Value>(&txt).unwrap_or_else(|_| json!({ "ok": out.status.success(), "raw": txt })))
}
fn looks_offline(v: &Value) -> bool {
let s = v.to_string().to_lowercase();
v.get("bridgeConnected").and_then(|b| b.as_bool()) == Some(false)
|| s.contains("not connected")
|| s.contains("no client")
|| s.contains("offline")
|| s.contains("timeout")
}
/// Deliver the symbol. `scope` is "symbol" (KiCad symbol lib) — Fusion always
/// gets a full `.lbr`. Returns a JSON result for the UI toast.
pub fn deliver(symbol_name: &str, kicad_sym: &str, target: &str) -> Value {
if kicad_sym.is_empty() {
return json!({ "ok": false, "error": "no symbol loaded" });
}
let dir = std::env::temp_dir().join(format!("adom-symbol-deliver-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let safe = symbol_name.replace(['/', ' '], "_");
let sym_path = dir.join(format!("{safe}.kicad_sym"));
if std::fs::write(&sym_path, kicad_sym).is_err() {
return json!({ "ok": false, "error": "could not write temp symbol" });
}
// KiCad takes the native symbol directly (symbol-only). We call the bridge's
// `kicad_install_symbol` verb, which merges the symbol into the Adom library
// AND opens it on the Symbol Editor canvas — then we VERIFY the bridge's
// success rather than assuming it (the old send_files + kicad_install_library
// path reported ok with nothing on the canvas). EAGLE-family tools (Fusion /
// Altium / OrCAD) import a true `.lbr` built by adom-lbr.
if target == "kicad" {
let resp = ad("kicad_install_symbol", json!({
"fileName": format!("{safe}.kicad_sym"),
"fileContent": b64(kicad_sym.as_bytes()),
"symbolName": symbol_name,
"openEditor": true,
"timeoutSeconds": 60,
}));
return summarize_kicad(symbol_name, &sym_path.to_string_lossy(), resp);
}
let (tool, app, folder) = match target {
"fusion360" | "fusion" => ("Fusion 360", "fusion360", "fusion"),
"altium" => ("Altium", "altium", "altium"),
"orcad" => ("OrCAD", "orcad", "orcad"),
_ => return json!({ "ok": false, "error": format!("unknown target {target}") }),
};
// Build the true library (.lbr) via adom-lbr.
let lbr_path = dir.join(format!("{safe}.lbr"));
match Command::new("adom-lbr").args(["generate", "--sym"]).arg(&sym_path).arg("--output").arg(&lbr_path).output() {
Ok(o) if o.status.success() => {}
Ok(o) => return json!({ "ok": false, "error": "adom-lbr generate failed", "detail": String::from_utf8_lossy(&o.stderr) }),
Err(e) => return json!({ "ok": false, "error": format!("adom-lbr not available: {e}") }),
}
// Altium & OrCAD have no Adom Desktop bridge YET — the EAGLE .lbr is built and
// ready, but there's no app integration to import it. Rather than pretend,
// invite the user to build the bridge (the wiki makes that a contribution).
if app == "altium" || app == "orcad" {
return bridge_not_built(tool, &lbr_path.to_string_lossy());
}
let send = ad("send_files", json!({ "filePaths": [lbr_path.to_string_lossy()], "targetApp": app, "destinationFolder": folder }));
let win = send.as_ref().ok()
.and_then(|s| s.get("destinationPaths").and_then(|p| p.as_array()).and_then(|a| a.first()).and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| lbr_path.to_string_lossy().to_string());
// Fusion has a dedicated open verb; Altium/OrCAD just receive the .lbr to import.
let mut steps = vec![send];
if app == "fusion360" {
steps.push(ad("fusion_open_lbr", json!({ "filePath": win })));
}
summarize(tool, "library (.lbr)", &steps)
}
/// Altium / OrCAD: the library is built but no Adom Desktop bridge exists for the
/// tool yet. Return a `needsBridge` signal the UI turns into a "build the bridge,
/// contribute on the wiki" call to action — that's how Adom grows its EDA reach.
fn bridge_not_built(tool: &str, file: &str) -> Value {
json!({
"ok": false,
"needsBridge": true,
"target": tool,
"file": file,
"contribute": "https://wiki.adom.inc/adom/adom-desktop",
"message": format!("Your EAGLE .lbr for {tool} is built and saved — but the {tool} ↔ Adom Desktop bridge isn't built yet. Want {tool} connectivity in the Adom ecosystem? Be the one to build the bridge and contribute it on the wiki.")
})
}
fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value {
for r in results {
match r {
Err(e) => return json!({ "ok": false, "target": tool, "error": e, "offline": e.contains("unreachable") }),
Ok(v) if looks_offline(v) => {
return json!({ "ok": false, "target": tool, "offline": true,
"message": format!("File generated, but Adom Desktop isn't connected to {tool}. Open Adom Desktop on your laptop and try again.") });
}
_ => {}
}
}
json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") })
}
/// Standard base64 (no external crate) — for sending file content through the bridge.
fn b64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
out.push(T[(b0 >> 2) as usize] as char);
out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(if chunk.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });
}
out
}
/// A descriptive paste-into-Claude prompt — always offered as a fallback so the
/// user can finish the delivery in their own AI thread if the bridge is offline
/// or anything is off. Self-contained: names the symbol, the file, and the goal.
fn claude_prompt(symbol_name: &str, sym_path: &str) -> String {
format!(
"I have a KiCad symbol named \"{symbol_name}\" saved at `{sym_path}`. Please install it into my KiCad \
\"Adom\" symbol library and open it in the KiCad Symbol Editor so I can see it on the canvas — use the \
adom-desktop KiCad bridge (`kicad_install_symbol` with the file's base64 content and openEditor:true), \
then confirm the symbol is actually open on the canvas."
)
}
/// Summarize a `kicad_install_symbol` bridge call. Verifies REAL success (the
/// bridge's `success` flag, or the confirmation text in its wrapped output) —
/// not just "doesn't look offline". Always includes a paste-into-Claude prompt.
fn summarize_kicad(symbol_name: &str, sym_path: &str, resp: Result<Value, String>) -> Value {
let prompt = claude_prompt(symbol_name, sym_path);
match resp {
Err(e) => json!({ "ok": false, "target": "KiCad", "offline": e.contains("unreachable"),
"error": e, "claudePrompt": prompt, "symbolPath": sym_path,
"message": "Couldn't reach Adom Desktop. Open it on your laptop and retry — or paste the prompt into Claude." }),
Ok(v) => {
if looks_offline(&v) {
return json!({ "ok": false, "target": "KiCad", "offline": true,
"claudePrompt": prompt, "symbolPath": sym_path,
"message": "Adom Desktop isn't connected. Open it on your laptop and retry — or paste the prompt into Claude." });
}
// AD may return the bridge result at the top level OR wrapped in `output`
// as a JSON string — check both. Success = the bridge's success flag, or
// its confirmation text ("loaded in the Symbol Editor" / "added to … library").
let s = v.to_string().to_lowercase();
let ok = v.get("success").and_then(|b| b.as_bool()) == Some(true)
|| s.contains("loaded in the symbol editor")
|| (s.contains("added to") && s.contains("library"));
if ok {
json!({ "ok": true, "target": "KiCad", "claudePrompt": prompt,
"message": format!("Installed \"{symbol_name}\" in the Adom library and opened it in the KiCad Symbol Editor.") })
} else {
json!({ "ok": false, "target": "KiCad", "claudePrompt": prompt,
"symbolPath": sym_path, "detail": v,
"message": "KiCad delivery didn't confirm. Paste the prompt into Claude to finish it, or open the file in KiCad manually." })
}
}
}
}