app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
//! adom-symbol — standalone Rust CLI for creating + previewing KiCad symbols.
//! No Node, no gallia. Generation is native Rust (`sym_gen`/`discrete_gen`);
//! SVG rendering goes through service-kicad (`render`).
mod provenance;
mod discrete_gen;
mod deliver;
mod ds2sf;
mod parse;
mod render;
mod server;
mod sym_gen;
mod theme;
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "adom-symbol", version, about = "Create and preview KiCad/Fusion 360 schematic symbols (standalone Rust, no gallia)")]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create a symbol from structured pin data (JSON on stdin or --file).
Create {
#[arg(long)]
file: Option<PathBuf>,
/// Output directory (default: ./<symbolName>/)
#[arg(long)]
out: Option<PathBuf>,
},
/// Render an existing .kicad_sym to a themed viewer HTML.
Preview {
/// Path to a .kicad_sym file
#[arg(long)]
file: PathBuf,
/// Symbol name (defaults to the first symbol in the file)
#[arg(long)]
name: Option<String>,
/// Where to write the viewer HTML (default: alongside the .kicad_sym)
#[arg(long)]
out: Option<PathBuf>,
},
/// Run the live app server (AI-drivable: /state, /eval, /console).
Serve {
#[arg(long, default_value_t = 8781)]
port: u16,
/// Optional .kicad_sym to load into the view on startup
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
},
/// Render any `.kicad_sym` to a themed thumbnail SVG (for source variants).
Render {
#[arg(long)]
file: PathBuf,
#[arg(long)]
out: PathBuf,
/// Optional iso 3D chip PNG to composite into the symbol body centre —
/// this is the "default symbol with the 3D chip in it" the dashboard
/// card shows. Usually `<dir>/<mpn>-3d-iso-icon.png` (transparent) or
/// `<mpn>-3d-iso.png`.
#[arg(long)]
iso: Option<PathBuf>,
/// 3D-chip size in the body: large (default, the historical size) | medium | small.
#[arg(long, default_value = "large")]
chip_size: String,
},
/// Render a ds2sf `<mpn>-symbol.extracted.json` to a themed thumbnail SVG
/// (auto-grouped via our generator). Used by chip-fetcher for the ds2sf
/// source variant. Also writes the generated .kicad_sym if --sym is given.
RenderDs2sf {
/// Path to the ds2sf extracted JSON
#[arg(long)]
file: PathBuf,
/// Output SVG path
#[arg(long)]
out: PathBuf,
/// Pin layout: left-right | all-sides
#[arg(long, default_value = "left-right")]
layout: String,
/// Also write the generated .kicad_sym here
#[arg(long)]
sym: Option<PathBuf>,
},
/// Drive the RUNNING live app's UI from the CLI (the ai-driven-apps contract:
/// every user action has a command that drives the real UI like a click).
/// Examples: `ui set 3d bold` · `ui set name chip` · `ui studio open` ·
/// `ui zoom fit` · `ui send kicad` · `ui hover 6` · `ui toast "hi"` ·
/// `ui state` · `ui list`. Requires `serve` running + a browser tab open.
Ui {
/// Action: set | studio | zoom | send | hover | toast | click | state | list
action: String,
/// Arguments to the action (e.g. `set 3d bold` → key=3d value=bold)
args: Vec<String>,
#[arg(long, default_value_t = 8781)]
port: u16,
},
/// Export a self-contained, INTERACTIVE HTML package (pan/zoom + a pin info
/// panel with datasheet descriptions) for embedding in an iframe — e.g. a
/// wiki component page. No server, no external assets: one portable .html.
Embed {
/// Path to a .kicad_sym file
#[arg(long)]
file: PathBuf,
/// Symbol name (defaults to the first symbol in the file)
#[arg(long)]
name: Option<String>,
/// Output .html path (default: <name>-embed.html next to the input)
#[arg(long)]
out: Option<PathBuf>,
},
}
// ── create input (matches the Node /sym/create body) ──
#[derive(serde::Deserialize)]
struct CreateInput {
#[serde(rename = "symbolName")]
symbol_name: String,
#[serde(default)]
manufacturer: String,
#[serde(default)]
package: String,
#[serde(default)]
description: String,
#[serde(default, rename = "datasheetUrl")]
datasheet_url: String,
#[serde(default = "default_category")]
category: String,
#[serde(default)]
pins: Vec<JsonPin>,
#[serde(default, rename = "referencePrefix")]
reference_prefix: Option<String>,
#[serde(default, rename = "discreteBaseline")]
discrete_baseline: Option<DiscreteBaseline>,
#[serde(default, rename = "pinOverrides")]
pin_overrides: Vec<JsonPinOverride>,
#[serde(default)]
preferences: Preferences,
/// Short human value for discretes (e.g. "33Ω"). Empty → symbol name.
#[serde(default)]
value: String,
/// Path to a `<mpn>-3d-outline-*.svg` wireframe to bake as a small offset
/// 3D-chip icon beside a discrete symbol.
#[serde(default, rename = "outlineSvg")]
outline_svg: Option<String>,
}
fn default_category() -> String {
"ic".to_string()
}
#[derive(serde::Deserialize)]
struct JsonPin {
name: String,
number: String,
#[serde(rename = "type")]
ptype: String,
#[serde(default)]
group: Option<String>,
#[serde(default)]
side: Option<String>,
}
#[derive(serde::Deserialize)]
struct DiscreteBaseline {
library: String,
symbol: String,
}
#[derive(serde::Deserialize, Default)]
struct JsonPinOverride {
#[serde(rename = "oldNumber")]
old_number: Option<String>,
#[serde(rename = "newNumber")]
new_number: Option<String>,
#[serde(rename = "oldName")]
old_name: Option<String>,
#[serde(rename = "newName")]
new_name: Option<String>,
}
#[derive(serde::Deserialize, Default)]
struct Preferences {
#[serde(default, rename = "pinLayout")]
pin_layout: Option<String>,
#[serde(default, rename = "chipNameCentered")]
chip_name_centered: bool,
#[serde(default, rename = "distributorPn")]
distributor_pn: Option<serde_json::Map<String, serde_json::Value>>,
}
fn main() {
if let Err(e) = run() {
eprintln!("ERROR: {e:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::Create { file, out } => cmd_create(file, out),
Cmd::Render { file, out, iso, chip_size } => {
let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let name = first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string());
let frac = render::chip_size_frac(&chip_size);
let mut svg = render::render_symbol_svg(&content, &name)?;
// The DEFAULT symbol layout shows the 3D chip as a WHITE vector OUTLINE
// with the chip name lasered on, composited into the body — matching the
// live adom-symbol app's default (three_d=bold + laserName). When no
// explicit overlay is passed, auto-discover that asset next to the
// symbol file. We pick a NATIVELY-white outline style (#e6edf3) so it
// reads on the dark symbol canvas in any SVG consumer — the app gets
// white by CSS-inverting the black "bold" SVG, but a saved standalone
// SVG can't rely on that filter, so we use the white style directly.
let overlay = iso.clone().or_else(|| default_white_outline(&file));
let mut composited = false;
let mut outline_note = String::new();
if let Some(p) = overlay.as_ref() {
match render::composite_iso_into_symbol(&svg, &content, &name, p, frac) {
Ok(s) => { svg = s; composited = true; }
Err(e) => eprintln!("WARN: 3D-chip composite skipped: {e}"),
}
// Emit the OUTLINE CONTRACT next to <mpn>-symbol.svg so adom-lbr
// (and other consumers) can transcribe the exact placement we drew
// — a lite drop-in overlay .mm.svg + a self-describing .json. Only
// for vector outlines (a raster --iso has no strokes to extract).
if p.extension().and_then(|e| e.to_str()) == Some("svg") {
let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or(&name).to_string();
let dir = out.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
let mm_name = format!("{stem}-outline.mm.svg");
let json_name = format!("{stem}-outline.json");
match fs::read_to_string(p).map_err(anyhow::Error::from).and_then(|ov| {
render::build_outline_contract(&svg, &content, &ov, p, &mm_name, frac)
}) {
Ok((mm_svg, json, segs)) => {
fs::write(dir.join(&mm_name), &mm_svg)
.with_context(|| format!("writing {mm_name}"))?;
fs::write(dir.join(&json_name), &json)
.with_context(|| format!("writing {json_name}"))?;
outline_note = format!(" (+outline contract: {mm_name} [{segs} segs] + {json_name})");
}
Err(e) => eprintln!("WARN: outline contract skipped: {e}"),
}
}
}
fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
println!("OK: rendered {} → {}{}{}", file.display(), out.display(),
if composited { " (+white 3D-chip outline + lasered name)" } else { "" },
outline_note);
Ok(())
}
Cmd::RenderDs2sf { file, out, layout, sym } => cmd_render_ds2sf(file, out, layout, sym),
Cmd::Ui { action, args, port } => cmd_ui(action, args, port),
Cmd::Embed { file, name, out } => cmd_embed(file, name, out),
Cmd::Preview { file, name, out } => cmd_preview(file, name, out),
Cmd::Serve { port, file, name: _ } => {
if let Some(f) = file {
// Full chip load (sources + 3D assets + library root for the
// switcher) when the file lives in a chip-fetcher library layout.
server::load_chip_from_file(&f)?;
}
server::run(port)
}
}
}
/// Parse a `<mpn>-3d-outline-*.svg` (polyline wireframe in a 0..N viewBox) into
/// KiCad-mm polylines scaled to ~30% (a ~3 mm box), offset to the LEFT of the
/// symbol body, y-flipped — the small 3D-chip icon baked beside a discrete.
fn parse_outline_svg(path: &str) -> Result<Vec<Vec<(f64, f64)>>> {
const OUT_BOX: f64 = 3.0;
const OUT_CX: f64 = -3.7;
const OUT_CY: f64 = 0.0;
let svg = fs::read_to_string(path).with_context(|| format!("reading {path}"))?;
let re = regex::Regex::new(r#"points="([^"]+)""#).unwrap();
let mut polys: Vec<Vec<(f64, f64)>> = Vec::new();
for c in re.captures_iter(&svg) {
let mut pts = Vec::new();
for pair in c[1].split_whitespace() {
let mut it = pair.split(',');
if let (Some(a), Some(b)) = (it.next(), it.next()) {
if let (Ok(x), Ok(y)) = (a.parse::<f64>(), b.parse::<f64>()) {
pts.push((x, y));
}
}
}
if pts.len() >= 2 {
polys.push(pts);
}
}
if polys.is_empty() {
return Ok(vec![]);
}
let (mut minx, mut maxx, mut miny, mut maxy) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for pl in &polys {
for &(x, y) in pl {
minx = minx.min(x);
maxx = maxx.max(x);
miny = miny.min(y);
maxy = maxy.max(y);
}
}
let (w, h) = (maxx - minx, maxy - miny);
let s = OUT_BOX / w.max(h).max(1e-6);
let (cx, cy) = ((minx + maxx) / 2.0, (miny + maxy) / 2.0);
Ok(polys
.into_iter()
.map(|pl| {
pl.into_iter()
.map(|(x, y)| (OUT_CX + (x - cx) * s, OUT_CY - (y - cy) * s))
.collect()
})
.collect())
}
fn cmd_create(file: Option<PathBuf>, out: Option<PathBuf>) -> Result<()> {
let body = match file {
Some(p) => fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?,
None => {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
}
};
let input: CreateInput = serde_json::from_str(&body).context("parsing create JSON")?;
if input.symbol_name.is_empty() {
return Err(anyhow!("symbolName is required"));
}
let kicad_sym = if input.category == "discrete" {
let baseline = input
.discrete_baseline
.as_ref()
.ok_or_else(|| anyhow!("discreteBaseline {{library,symbol}} required for category=discrete"))?;
let dinput = discrete_gen::DiscreteInput {
symbol_name: input.symbol_name.clone(),
manufacturer: input.manufacturer.clone(),
package: input.package.clone(),
description: input.description.clone(),
datasheet_url: input.datasheet_url.clone(),
reference_prefix: input.reference_prefix.clone(),
baseline_library: baseline.library.clone(),
baseline_symbol: baseline.symbol.clone(),
pin_overrides: input
.pin_overrides
.iter()
.map(|o| discrete_gen::PinOverride {
old_number: o.old_number.clone(),
new_number: o.new_number.clone(),
old_name: o.old_name.clone(),
new_name: o.new_name.clone(),
})
.collect(),
value: input.value.clone(),
outline_polylines: match &input.outline_svg {
Some(p) => parse_outline_svg(p).unwrap_or_default(),
None => vec![],
},
};
discrete_gen::generate_discrete_sym(&dinput, &fetch_lib_from_service)?
} else {
if input.pins.is_empty() {
return Err(anyhow!("pins array is required for category=ic"));
}
let icinput = sym_gen::IcInput {
symbol_name: input.symbol_name.clone(),
manufacturer: input.manufacturer.clone(),
package: input.package.clone(),
description: input.description.clone(),
datasheet_url: input.datasheet_url.clone(),
reference_prefix: input.reference_prefix.clone().unwrap_or_else(|| "U".to_string()),
pins: input
.pins
.iter()
.map(|p| sym_gen::Pin {
name: p.name.clone(),
number: p.number.clone(),
ptype: p.ptype.clone(),
group: p.group.clone(),
side: p.side.clone(),
})
.collect(),
pin_layout: input.preferences.pin_layout.clone().unwrap_or_else(|| "left-right".to_string()),
chip_name_centered: input.preferences.chip_name_centered,
ref_pos: "top".to_string(),
value_pos: "out-bot".to_string(),
distributor_pn: input
.preferences
.distributor_pn
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect())
.unwrap_or_default(),
};
sym_gen::generate_ic_sym(&icinput)
};
let folder = out.unwrap_or_else(|| PathBuf::from(&input.symbol_name));
fs::create_dir_all(&folder)?;
let sym_path = folder.join(format!("{}.kicad_sym", input.symbol_name));
fs::write(&sym_path, &kicad_sym)?;
let (svg, html_path) = render_and_view(&kicad_sym, &input.symbol_name, &folder, Some(&input))?;
let svg_path = folder.join(format!("{}.svg", input.symbol_name));
fs::write(&svg_path, &svg)?;
println!("created {}", sym_path.display());
println!("svg {}", svg_path.display());
println!("viewer {}", html_path.display());
Ok(())
}
fn cmd_render_ds2sf(file: PathBuf, out: PathBuf, layout: String, sym: Option<PathBuf>) -> Result<()> {
let json = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let parsed = ds2sf::parse(&json)?;
let opts = ds2sf::LayoutOpts { pin_layout: layout, grouped: true, ref_pos: "out-top".to_string(), value_pos: "out-bot".to_string() };
let (input, _pin_desc, _group_desc) = ds2sf::build_ic_input(&parsed, &opts);
if input.pins.is_empty() {
return Err(anyhow!("ds2sf extraction has no pins"));
}
let kicad_sym = sym_gen::generate_ic_sym(&input);
if let Some(sp) = sym {
fs::write(&sp, &kicad_sym).with_context(|| format!("writing {}", sp.display()))?;
}
let svg = render::render_symbol_svg(&kicad_sym, &input.symbol_name)?;
fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
println!("OK: rendered ds2sf symbol → {} ({} pins)", out.display(), input.pins.len());
Ok(())
}
fn cmd_preview(file: PathBuf, name: Option<String>, out: Option<PathBuf>) -> Result<()> {
let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let symbol_name = name.unwrap_or_else(|| first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string()));
let folder = out.unwrap_or_else(|| file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")));
let (_svg, html_path) = render_and_view(&content, &symbol_name, &folder, None)?;
println!("viewer {}", html_path.display());
Ok(())
}
fn render_and_view(kicad_sym: &str, symbol_name: &str, folder: &std::path::Path, input: Option<&CreateInput>) -> Result<(String, PathBuf)> {
let svg = render::render_symbol_svg(kicad_sym, symbol_name)?;
let (mfr, pkg, desc) = match input {
Some(i) => (i.manufacturer.clone(), i.package.clone(), i.description.clone()),
None => (String::new(), String::new(), String::new()),
};
let html = build_viewer_html(symbol_name, &svg, &mfr, &pkg, &desc);
let html_path = folder.join(format!("{symbol_name}-viewer.html"));
fs::write(&html_path, &html)?;
Ok((svg, html_path))
}
/// Drive the running app's `window.ui` API over the /eval channel.
fn cmd_ui(action: String, args: Vec<String>, port: u16) -> Result<()> {
let arglist: Vec<String> = args.iter().map(|a| serde_json::to_string(a).unwrap_or_else(|_| format!("{a:?}"))).collect();
let code = format!("(window.ui && window.ui.{action}) ? window.ui.{action}({}) : 'window.ui not ready — is a browser tab open?'", arglist.join(","));
let base = format!("http://127.0.0.1:{port}");
let client = reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(10)).build()?;
let resp = client
.post(format!("{base}/eval"))
.json(&serde_json::json!({ "code": code }))
.send()
.with_context(|| format!("POST {base}/eval — is the app running? (adom-symbol serve --port {port})"))?;
let id = resp.json::<serde_json::Value>()?.get("id").and_then(|v| v.as_str()).map(String::from).ok_or_else(|| anyhow!("no eval id from server"))?;
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(120));
let v: serde_json::Value = client.get(format!("{base}/eval/{id}")).send()?.json().unwrap_or(serde_json::Value::Null);
// server wraps as { done, result: { error, result } } (the front-end's payload)
if v.get("done").and_then(|d| d.as_bool()) == Some(true) {
let inner = v.get("result").cloned().unwrap_or(serde_json::Value::Null);
if let Some(err) = inner.get("error") {
if !err.is_null() { return Err(anyhow!("UI error: {}", err.as_str().unwrap_or(""))); }
}
match inner.get("result") {
Some(r) if r.is_string() => println!("{}", r.as_str().unwrap_or("")),
Some(r) if !r.is_null() => println!("{}", serde_json::to_string_pretty(r).unwrap_or_default()),
_ => println!("ok"),
}
return Ok(());
}
}
Err(anyhow!("timed out — open a browser tab on http://127.0.0.1:{port}/ so the UI can run the command"))
}
fn cmd_embed(file: PathBuf, name: Option<String>, out: Option<PathBuf>) -> Result<()> {
let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let symbol_name = name.unwrap_or_else(|| first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string()));
let mut svg = render::render_symbol_svg(&content, &symbol_name)?;
// Bake the 3D-chip outline into the symbol body — the signature look the live
// app and `render` show — when the sibling white outline asset exists.
let outline = default_white_outline(&file);
if let Some(p) = outline.as_ref() {
let frac = render::chip_size_frac("large");
match render::composite_iso_into_symbol(&svg, &content, &symbol_name, p, frac) {
Ok(s) => svg = s,
Err(e) => eprintln!("WARN: 3D-chip composite skipped: {e}"),
}
}
let meta = parse::parse_kicad_sym(&content, &symbol_name);
let html = build_embed_html(&symbol_name, &svg, &meta);
let out = out.unwrap_or_else(|| {
let folder = file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
folder.join(format!("{symbol_name}-embed.html"))
});
fs::write(&out, &html).with_context(|| format!("writing {}", out.display()))?;
println!("OK: interactive embed ({} pins) → {}", meta.pins.len(), out.display());
Ok(())
}
/// Build a self-contained, INTERACTIVE HTML viewer (pan/zoom + a filterable pin
/// panel with datasheet descriptions) ready to drop into an `<iframe>`.
pub(crate) fn build_embed_html(name: &str, svg: &str, meta: &parse::SymbolMeta) -> String {
let pins: Vec<serde_json::Value> = meta
.pins
.iter()
.map(|p| {
serde_json::json!({
"number": p.number, "name": p.name, "type": p.r#type,
"color": p.color, "desc": p.desc,
// geometry so the embed can place pin-hover hit zones exactly
// where the live SymView does (KiCad symbol-local mm, Y-up).
"x": p.x, "y": p.y, "angle": p.angle, "length": p.length,
})
})
.collect();
let pins_json = serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into());
// The symbol->SVG-unit offset that aligns pin geometry to the rendered glyphs
// (same helper the live server hands the app as `state.offset`).
let offset_json = match parse::compute_offset(svg, &meta.pins) {
Some((x, y)) => format!("{{\"x\":{x},\"y\":{y}}}"),
None => "null".into(),
};
// Canonical-header subject-meta: "· N pins · <package>" (JetBrains-Mono row).
// Package = the footprint's library-qualified tail (e.g. "…:QFN-56" -> "QFN-56").
let pkg = meta.footprint.rsplit(':').next().unwrap_or("").trim();
let mut subj_meta = format!("<span class=\"sep\">·</span><span>{} pins</span>", meta.pins.len());
if !pkg.is_empty() {
subj_meta.push_str(&format!("<span class=\"sep\">·</span><span>{}</span>", esc(pkg)));
}
// App-info popover "about" line — the app subtitle, not the part's datasheet blurb.
let about = "Interactive KiCad schematic-symbol viewer: pan/zoom, per-pin hover, and a filterable pin list.";
let mfr = meta.manufacturer.trim();
let mfr_row = if mfr.is_empty() {
String::new()
} else {
format!("<div class=\"popover-row\"><span class=\"k\">mfr</span><span class=\"v\">{}</span></div>", esc(mfr))
};
// Bottom info bar items (MFR · PKG · PINS · DESC) — mirrors the live app footer.
let mut foot = String::new();
for (k, v) in [("MFR", mfr), ("PKG", pkg)] {
if !v.is_empty() {
foot.push_str(&format!("<span class=\"it\"><span class=\"k\">{k}</span><span class=\"v\">{}</span></span>", esc(v)));
}
}
foot.push_str(&format!("<span class=\"it\"><span class=\"k\">PINS</span><span class=\"v\">{}</span></span>", meta.pins.len()));
let desc = meta.description.trim();
if !desc.is_empty() {
let d: String = if desc.chars().count() > 120 {
format!("{}…", desc.chars().take(120).collect::<String>())
} else {
desc.to_string()
};
foot.push_str(&format!("<span class=\"it descit\"><span class=\"k\">DESC</span><span class=\"v\">{}</span></span>", esc(&d)));
}
include_str!("embed.html")
.replace("/*__VIEWER_CORE__*/", include_str!("viewer_core.js"))
.replace("__NAME__", &esc(name))
.replace("__SVG__", svg)
.replace("__PINS__", &pins_json)
.replace("__OFFSET__", &offset_json)
.replace("__META__", &subj_meta)
.replace("__ABOUT__", about)
.replace("__MFR_ROW__", &mfr_row)
.replace("__FOOTER__", &foot)
.replace("__PINS_N__", &meta.pins.len().to_string())
.replace("__VERSION__", env!("CARGO_PKG_VERSION"))
}
fn fetch_lib_from_service(library: &str) -> Result<String> {
let url = format!(
"{}/symbols/{}.kicad_sym",
std::env::var("KICAD_SERVICE_API").unwrap_or_else(|_| "https://kicad-rk5ue5pcfemi.adom.cloud".to_string()).trim_end_matches('/'),
library
);
let resp = reqwest::blocking::get(&url).with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
return Err(anyhow!("service-kicad {} for library {library}", resp.status()));
}
Ok(resp.text()?)
}
pub(crate) fn first_symbol_name(content: &str) -> Option<String> {
let i = content.find("(symbol \"")? + "(symbol \"".len();
let j = content[i..].find('"')? + i;
Some(content[i..j].to_string())
}
/// The default 3D-chip overlay for a symbol render: the lasered-name vector
/// outline chip-thumbnailer drops next to `<mpn>.kicad_sym`
/// (`<mpn>-3d-outline-<style>-named.svg`). Canonical = **heavy** (white `#e6edf3`,
/// thick) — confirmed by John 2026-06-28. We then fall back through the other
/// SINGLE-VISIBLE-LAYER styles (white thickness ladder, then the teal accent),
/// and deliberately AVOID the multi-layer styles (`blueprint`/`xray` add hidden
/// dashed edges; `neon` is a 2-layer glow) — they read as clutter when embedded
/// as schematic `<wire>`s. Returns None if no outline asset exists yet (plain).
fn default_white_outline(file: &std::path::Path) -> Option<PathBuf> {
let mpn = file.file_stem()?.to_str()?;
let dir = file.parent().unwrap_or_else(|| std::path::Path::new("."));
for style in ["heavy", "dark", "hairline", "teal"] {
let p = dir.join(format!("{mpn}-3d-outline-{style}-named.svg"));
if p.is_file() {
return Some(p);
}
}
None
}
fn build_viewer_html(name: &str, svg: &str, mfr: &str, pkg: &str, desc: &str) -> String {
let meta: String = [mfr, pkg].iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join(" · ");
let desc_html = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) };
let meta_html = if meta.is_empty() { String::new() } else { format!("<div class=\"meta\">{}</div>", esc(&meta)) };
format!(
r#"<!doctype html><html><head><meta charset="utf-8"><title>{title} — adom-symbol</title>
<style>
:root {{ --bg:#0d1117; --panel:#11161f; --border:#222b38; --text:#e6edf3; --muted:#8b949e; --accent:#00e6dc; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; min-height:100vh; display:flex; flex-direction:column; }}
header {{ display:flex; align-items:center; gap:10px; padding:14px 22px; border-bottom:1px solid var(--border); }}
header .dot {{ width:10px; height:10px; border-radius:50%; background:var(--accent); box-shadow:0 0 12px var(--accent); }}
header .brand {{ font-weight:700; letter-spacing:.2px; }}
header .brand span {{ color:var(--muted); font-weight:400; }}
.wrap {{ flex:1; display:flex; align-items:center; justify-content:center; padding:32px; }}
.card {{ background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:22px 26px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(92vw,720px); }}
.name {{ font-size:22px; font-weight:700; }}
.meta {{ color:var(--muted); font-size:13px; margin-top:2px; }}
.desc {{ color:#aeb6c2; font-size:13px; margin-top:6px; line-height:1.4; }}
.svgbox {{ margin-top:16px; border:1px solid var(--border); border-radius:10px; background:#0d1117; overflow:hidden; }}
.svgbox svg {{ display:block; width:100%; height:auto; max-height:64vh; }}
footer {{ text-align:center; color:var(--muted); font-size:11px; padding:10px; }}
</style></head>
<body>
<header><span class="dot"></span><span class="brand">adom-symbol <span>· KiCad symbol viewer</span></span></header>
<div class="wrap">
<div class="card">
<div class="name">{title}</div>
{meta_html}
{desc_html}
<div class="svgbox">{svg}</div>
</div>
</div>
<footer>rendered via service-kicad — standalone, no local KiCad</footer>
</body></html>"#,
title = esc(name),
meta_html = meta_html,
desc_html = desc_html,
svg = svg,
)
}
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}