app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130
//! adom-symbol live app server — the AI-drivable surface (app-creator §7/§7b/§8).
//! tiny_http server: GET / (UI), GET /state (poller), POST /load (push a symbol),
//! POST /eval + GET /eval/pending + POST /eval/:id/result + GET /eval/:id (eval
//! channel), POST/GET /console (console forwarding), POST /shutdown, GET
//! /favicon.svg (monochrome). No gallia, no Node.
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::io::Read;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
#[derive(Default, Clone)]
pub struct AppState {
pub symbol_name: String,
pub svg: String,
pub manufacturer: String,
pub package: String,
pub description: String,
pub datasheet: String,
pub pin_count: usize,
pub pins: Vec<crate::parse::Pin>,
pub groups: Vec<crate::parse::Group>,
pub offset: Option<(f64, f64)>,
pub hide_pin_names: bool,
pub body_rect: Option<crate::parse::BodyRect>,
// ── studio: source + layout options ──
pub sources: Vec<String>, // available, e.g. ["auto","manufacturer","ds2sf"]
pub source: String, // current
pub pin_layout: String, // "left-right" | "all-sides"
pub grouped: bool,
pub ref_pos: String,
pub value_pos: String, // chip-name placement (same set as ref_pos; "none" = hidden)
pub laser_name: bool, // "laser" the chip name onto the 3D chip's top surface
pub three_d: String, // "off" | "iso" | "thin" | "bold" | "blueprint" | "teal" | "dark"
pub three_d_assets: std::collections::HashMap<String, String>, // mode → data: URL
pub source_labels: std::collections::HashMap<String, String>, // method → display label
pub kicad_sym: String, // raw .kicad_sym of the current symbol (for Send-to delivery)
pub saved_layout: bool, // this chip has per-symbol prefs saved (sticky must NOT override)
pub chip_size: String, // 3D-chip size: large|medium|small (per-symbol when saved)
// Effective Reference/Value designator positions (KiCad symbol-local mm, Y-up)
// as actually rendered — the app uses these to place the draggable field handles.
pub ref_field: Option<(f64, f64)>,
pub value_field: Option<(f64, f64)>,
// The exact rendered text of each designator (e.g. "U?", "AD7124-8") so the
// app can match the precise on-screen <text> for the drag box (not a pin name).
pub ref_text: String,
pub value_text: String,
}
/// Map a source slug to a friendly label. Delegates to the SHARED provenance
/// rule (see the chip-provenance-labeling skill): label by where it was actually
/// downloaded, name the maker site, never call an aggregator "Manufacturer".
/// Label a source for the picker. `discovery_label` is the human name of where
/// the whole library bundle was downloaded (e.g. "Component Search Engine") —
/// the format variants (altium/fusion) came from that SAME bundle, so they're
/// named "<discovery> - <Format> Library", matching chip-fetcher's carat.
fn source_label(slug: &str, manufacturer: &str, discovery_label: &str) -> String {
match slug {
"auto" => "Auto · grouped".to_string(),
"ds2sf" => format!("ds2sf {}", crate::provenance::source_label("manufacturer", manufacturer)),
// The format variants of the SAME downloaded bundle — name the format, not "KiCad".
"manufacturer" => format!("{discovery_label} - KiCad Library"),
"altium" => format!("{discovery_label} - Altium Library"),
"fusion" | "fusion360" => format!("{discovery_label} - Fusion 360 Library"),
// A KiCad-format variant discovered from a different vendor → name that source.
other => format!("{} - KiCad Library", crate::provenance::source_label(other, manufacturer)),
}
}
/// What the studio is currently showing — drives /relayout regeneration.
#[derive(Default, Clone)]
struct Model {
dir: String,
mpn: String,
has_ds2sf: bool,
has_mfr: bool,
variants: Vec<String>, // vendor variant methods: <mpn>.<method>.kicad_sym
dir_for_3d: String, // chip folder (to load iso PNG + outline SVGs)
mpn_axis: String, // chosen_up_axis (for label only)
source: String,
pin_layout: String,
grouped: bool,
ref_pos: String,
value_pos: String,
laser_name: bool,
three_d: String, // "off" | "iso" | outline style
chip_size: String, // 3D-chip size: large|medium|small
saved: bool, // per-symbol prefs were loaded from info.json (sticky must not override)
// Custom Reference/Value designator positions (KiCad symbol-local mm, Y-up).
// Some = the user dragged it / auto-placed it → overrides the discrete refPos.
// Cleared when a discrete refPos/valuePos preset is picked.
ref_x: Option<f64>,
ref_y: Option<f64>,
value_x: Option<f64>,
value_y: Option<f64>,
}
static MODEL: Mutex<Option<Model>> = Mutex::new(None);
// The chip-fetcher library root (folder of <MPN>/ dirs) — powers the left
// chip-library switcher. Set when a chip is loaded from a library folder.
static LIBRARY_ROOT: Mutex<Option<String>> = Mutex::new(None);
/// Render `kicad_sym` (by its real `render_name`) and parse it into a fully
/// interactive AppState. `display_name` is the label shown (e.g. the MPN);
/// the metadata args override parsed values when non-empty.
pub fn build_symbol_state(
kicad_sym: &str,
render_name: &str,
display_name: &str,
manufacturer: &str,
package: &str,
description: &str,
datasheet: &str,
) -> Result<AppState> {
let svg = crate::render::render_symbol_svg(kicad_sym, render_name)?;
let meta = crate::parse::parse_kicad_sym(kicad_sym, render_name);
let offset = crate::parse::compute_offset(&svg, &meta.pins);
let pick = |over: &str, parsed: &str| if over.is_empty() { parsed.to_string() } else { over.to_string() };
Ok(AppState {
symbol_name: if display_name.is_empty() { render_name.to_string() } else { display_name.to_string() },
svg,
manufacturer: pick(manufacturer, &meta.manufacturer),
package: pick(package, &meta.footprint),
description: pick(description, &meta.description),
datasheet: pick(datasheet, &meta.datasheet),
pin_count: meta.pins.len(),
pins: meta.pins,
groups: meta.groups,
offset,
hide_pin_names: meta.hide_pin_names,
body_rect: meta.body_rect,
kicad_sym: kicad_sym.to_string(),
ref_field: crate::render::field_at(kicad_sym, render_name, "Reference"),
value_field: crate::render::field_at(kicad_sym, render_name, "Value"),
ref_text: crate::render::field_val(kicad_sym, render_name, "Reference").map(|p| format!("{p}?")).unwrap_or_default(),
value_text: render_name.to_string(),
..Default::default()
})
}
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());
static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);
static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);
static EVAL_ID: AtomicU64 = AtomicU64::new(1);
const APP_HTML: &str = include_str!("app.html");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="4" width="10" height="16" rx="1.5"/><path d="M7 8H3M7 12H3M7 16H3M21 8h-4M21 12h-4M21 16h-4"/></svg>"##;
static REV: AtomicU64 = AtomicU64::new(0);
// True while a chip-thumbnailer regen (3D outlines + iso) is running for the
// loaded chip — surfaced in /state so the app spins the ⟳ Regen button.
static REGEN_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_state(s: AppState) {
REV.fetch_add(1, Ordering::SeqCst);
*STATE.lock().unwrap() = Some(s);
}
fn state_json() -> String {
// Pre-read the raw field overrides (MODEL) BEFORE locking STATE, so we never
// nest the two locks (avoids any lock-ordering hazard with save/regen paths).
let (ref_ovr, val_ovr): (Option<[f64; 2]>, Option<[f64; 2]>) = MODEL
.lock()
.unwrap()
.as_ref()
.map(|m| (
match (m.ref_x, m.ref_y) { (Some(x), Some(y)) => Some([x, y]), _ => None },
match (m.value_x, m.value_y) { (Some(x), Some(y)) => Some([x, y]), _ => None },
))
.unwrap_or((None, None));
match &*STATE.lock().unwrap() {
// Lightweight: NO svg, NO 3D asset data (those are fetched on demand via
// /svg and /3d/:mode). `rev` bumps whenever the symbol is regenerated so
// the client knows when to re-fetch the heavy SVG. Keeps the 1s poll tiny.
Some(s) => {
// Hide the internal "<style>-named" keys from the mode list (they're
// selected implicitly by the laser-name toggle, not user-facing styles).
let mut modes: Vec<&String> = s.three_d_assets.keys().filter(|k| !k.ends_with("-named")).collect();
modes.sort();
// When the chip name is laser-marked on the chip, serve the
// name-marked variant of the current 3D style (iso emboss render, or
// the flat-top outline) when one exists.
let named_key = format!("{}-named", s.three_d);
let cur_asset = if s.laser_name && s.three_d_assets.contains_key(&named_key) {
s.three_d_assets.get(&named_key)
} else {
s.three_d_assets.get(&s.three_d)
}
.cloned()
.unwrap_or_default();
json!({
"rev": REV.load(Ordering::SeqCst),
"regen": REGEN_RUNNING.load(Ordering::SeqCst),
"symbolName": s.symbol_name, "manufacturer": s.manufacturer,
"package": s.package, "description": s.description, "datasheet": s.datasheet,
"pinCount": s.pin_count, "pins": s.pins, "groups": s.groups,
"offset": s.offset.map(|(x, y)| json!({ "x": x, "y": y })),
"hidePinNames": s.hide_pin_names,
"bodyRect": s.body_rect,
"sources": s.sources, "source": s.source, "pinLayout": s.pin_layout,
"grouped": s.grouped, "refPos": s.ref_pos, "valuePos": s.value_pos, "laserName": s.laser_name, "threeD": s.three_d,
"savedLayout": s.saved_layout, "chipSize": s.chip_size,
// Effective designator positions (KiCad mm, Y-up) for the draggable handles.
"refField": s.ref_field.map(|(x, y)| json!({ "x": x, "y": y })),
"valueField": s.value_field.map(|(x, y)| json!({ "x": x, "y": y })),
"refText": s.ref_text, "valueText": s.value_text,
// Raw custom overrides (null = following the discrete preset) — lets
// the client's Undo restore the exact preset-vs-dragged state.
"refOverride": ref_ovr,
"valueOverride": val_ovr,
"threeDModes": modes,
// only the CURRENT mode's asset (sync, reliable) — not all 6.
"threeDAsset": cur_asset,
"sourceLabels": s.source_labels,
})
.to_string()
}
None => "{}".to_string(),
}
}
fn read_file(dir: &str, name: &str) -> Option<String> {
std::fs::read_to_string(std::path::Path::new(dir).join(name)).ok()
}
/// Load the centered-3D assets for the studio: the shaded iso PNG (at the user's
/// chosen axis) keyed "iso", plus every vector outline `<mpn>-3d-outline-<style>.svg`
/// keyed by its style. Values are data: URLs the viewer overlays in the body centre.
fn load_3d_assets(dir: &str, mpn: &str, axis: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let p = std::path::Path::new(dir);
let iso_file = match axis {
"y" | "yup" => format!("{mpn}-3d-iso-yup.png"),
"x" | "xup" => format!("{mpn}-3d-iso-xup.png"),
"xdown" | "-x" => format!("{mpn}-3d-iso-xdown.png"),
"ydown" | "-y" => format!("{mpn}-3d-iso-ydown.png"),
"zdown" | "-z" => format!("{mpn}-3d-iso-zdown.png"),
_ => format!("{mpn}-3d-iso.png"),
};
// Prefer the transparent-background iso icon; fall back to the shaded ones.
if let Ok(b) = std::fs::read(p.join(format!("{mpn}-3d-iso-icon.png")))
.or_else(|_| std::fs::read(p.join(&iso_file)))
.or_else(|_| std::fs::read(p.join(format!("{mpn}-3d-iso.png"))))
{
out.insert("iso".to_string(), format!("data:image/png;base64,{}", b64(&b)));
}
// Name-embossed iso (OCCT-marked STEP rendered, then made transparent): the
// part number is real raised geometry on the chip's top face. Used by the
// "laser the name on the chip" mode so the mark is perspective-correct and
// matches what ships to KiCad. Optional — only present once generated.
if let Ok(b) = std::fs::read(p.join(format!("{mpn}-3d-iso-named-icon.png")))
.or_else(|_| std::fs::read(p.join(format!("{mpn}-3d-iso-named.png"))))
{
out.insert("iso-named".to_string(), format!("data:image/png;base64,{}", b64(&b)));
}
// Vector outlines: <mpn>-3d-outline-<style>.svg → data:image/svg+xml. The
// name-marked variants are <style>-named.svg (the part number drawn flat on
// the top face — top-edge only, no extrusion clutter); they map to the
// "<style>-named" key, served when the laser-name mode is on.
let prefix = format!("{mpn}-3d-outline-");
if let Ok(rd) = std::fs::read_dir(p) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if let Some(rest) = name.strip_prefix(&prefix) {
if let Some(style) = rest.strip_suffix(".svg") {
if let Ok(s) = std::fs::read_to_string(e.path()) {
// style may be "thin" or "thin-named" — both become keys verbatim.
out.insert(style.to_string(), format!("data:image/svg+xml;base64,{}", b64(s.as_bytes())));
}
}
}
}
}
out
}
/// Discover the symbol sources in a chip-fetcher library folder and render the
/// default (auto-grouped from ds2sf if present, else the manufacturer symbol).
fn load_from_dir(dir: &str, mpn: &str, init_source: &str) {
// Remember the library root (this chip's parent folder) for the switcher.
if let Some(parent) = std::path::Path::new(dir).parent() {
*LIBRARY_ROOT.lock().unwrap() = Some(parent.to_string_lossy().to_string());
}
let has_ds2sf = read_file(dir, &format!("{mpn}-symbol.extracted.json")).is_some();
let has_mfr = read_file(dir, &format!("{mpn}.kicad_sym")).is_some();
// Discover per-source vendor variants: <mpn>.<method>.kicad_sym
let mut variants = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
let suffix = ".kicad_sym";
let prefix = format!("{mpn}.");
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name == format!("{mpn}.kicad_sym") || !name.ends_with(suffix) || !name.starts_with(&prefix) {
continue;
}
let method = &name[prefix.len()..name.len() - suffix.len()];
// `vendor`/`prev` are auto-save BACKUPS, not provenance sources — don't
// list them in the Source carat.
if !method.is_empty() && !method.contains('.') && method != "vendor" && method != "prev" {
variants.push(method.to_string());
}
}
}
let axis = read_file(dir, "info.json")
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("chosen_up_axis").and_then(|a| a.as_str()).map(String::from))
.unwrap_or_default();
// Honor an explicit initial source from the carat (ds2sf vs a vendor method);
// otherwise default to auto-grouped (ds2sf) when available.
let default_source = match init_source {
"ds2sf" if has_ds2sf => "ds2sf".to_string(),
"auto" if has_ds2sf => "auto".to_string(),
s if !s.is_empty() && s != "ds2sf" && has_mfr => "manufacturer".to_string(),
_ if has_ds2sf => "auto".to_string(),
_ => "manufacturer".to_string(),
};
// Per-symbol saved prefs (info.json → "adom_symbol") WIN over the sticky
// defaults. If present, this chip was deliberately set by the user, so we
// restore exactly those and flag `saved` so the client won't re-apply sticky.
let saved = read_saved_prefs(dir, mpn);
let g = |k: &str, d: &str| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_str()).map(String::from).unwrap_or_else(|| d.to_string());
let gb = |k: &str, d: bool| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_bool()).unwrap_or(d);
let gn = |k: &str| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_f64());
*MODEL.lock().unwrap() = Some(Model {
dir: dir.to_string(),
mpn: mpn.to_string(),
has_ds2sf,
has_mfr,
variants,
dir_for_3d: dir.to_string(),
mpn_axis: axis,
source: g("source", &default_source),
pin_layout: g("pinLayout", "left-right"),
grouped: gb("grouped", true),
ref_pos: g("refPos", "out-top"),
value_pos: g("valuePos", "none"), // name lives ON the chip by default
laser_name: gb("laserName", true),
three_d: g("threeD", "bold"),
chip_size: g("chipSize", "large"),
saved: saved.is_some(),
ref_x: gn("refX"),
ref_y: gn("refY"),
value_x: gn("valueX"),
value_y: gn("valueY"),
});
regenerate();
}
/// Read this chip's per-symbol prefs block (`info.json` → `adom_symbol`), if any.
fn read_saved_prefs(dir: &str, _mpn: &str) -> Option<Value> {
let v: Value = serde_json::from_str(&read_file(dir, "info.json")?).ok()?;
v.get("adom_symbol").filter(|x| x.is_object()).cloned()
}
/// Persist this chip's per-symbol prefs into `info.json` → `adom_symbol`, merging
/// with the rest of info.json. This is what makes a deliberately-set symbol stick.
fn write_saved_prefs(dir: &str, prefs: Value) {
let path = std::path::Path::new(dir).join("info.json");
let mut root: Value = std::fs::read_to_string(&path).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| json!({}));
if !root.is_object() { root = json!({}); }
root["adom_symbol"] = prefs;
if let Ok(s) = serde_json::to_string_pretty(&root) {
let _ = std::fs::write(&path, s);
}
}
fn library_root() -> Option<String> {
LIBRARY_ROOT.lock().unwrap().clone()
}
/// Every chip in the library root (a `<MPN>/<MPN>.kicad_sym` folder), sorted, with
/// the currently-loaded one flagged. Powers the left chip-library switcher.
fn list_chips() -> Vec<(String, bool)> {
let root = match library_root() {
Some(r) => r,
None => return vec![],
};
let cur = MODEL.lock().unwrap().as_ref().map(|m| m.mpn.clone()).unwrap_or_default();
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&root) {
for e in rd.flatten() {
if !e.path().is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if e.path().join(format!("{name}.kicad_sym")).is_file() {
let is_cur = name == cur;
out.push((name, is_cur));
}
}
}
out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
out
}
/// Build the standalone interactive embed HTML for the CURRENTLY-shown symbol —
/// the exact layout on screen (source/pins/3D/labels), composited with the 3D
/// outline, so "open interactive viewer" always reflects what the studio shows.
fn build_embed_for(ks: &str, dir: &str, mpn: &str) -> Option<String> {
if ks.trim().is_empty() { return None; }
let render_name = crate::first_symbol_name(ks)?;
let mut svg = crate::render::render_symbol_svg(ks, &render_name).ok()?;
if let Some(p) = crate::render::default_white_outline_in(std::path::Path::new(dir), mpn) {
let frac = crate::render::chip_size_frac("large");
if let Ok(s) = crate::render::composite_iso_into_symbol(&svg, ks, &render_name, &p, frac) {
svg = s;
}
}
let meta = crate::parse::parse_kicad_sym(ks, &render_name);
Some(crate::build_embed_html(&render_name, &svg, &meta))
}
fn build_current_embed() -> Option<String> {
let (dir, mpn) = { let md = MODEL.lock().unwrap(); let m = md.as_ref()?; (m.dir.clone(), m.mpn.clone()) };
let ks = { let st = STATE.lock().unwrap(); st.as_ref()?.kicad_sym.clone() };
build_embed_for(&ks, &dir, &mpn)
}
/// Persist the current symbol back to its library folder so chip-fetcher and
/// adom-lbr see the latest: write the canonical `<mpn>.kicad_sym` (backing up the
/// original vendor file once to `<mpn>.vendor.kicad_sym`) and re-render
/// `<mpn>-symbol.svg` + the outline contract at the chosen chip size. Always
/// re-renders the thumbnail (so a size change persists) even when the symbol
/// itself is unchanged. Returns true if there was a chip to save.
fn save_current(chip_size: &str) -> bool {
let (dir, mpn) = match MODEL.lock().unwrap().as_ref() {
Some(m) => (m.dir.clone(), m.mpn.clone()),
None => return false,
};
if dir.is_empty() || mpn.is_empty() {
return false;
}
let ks = match STATE.lock().unwrap().as_ref() {
Some(s) => s.kicad_sym.clone(),
None => return false,
};
if ks.trim().is_empty() {
return false;
}
let dirp = std::path::Path::new(&dir);
let canon = dirp.join(format!("{mpn}.kicad_sym"));
let existing = std::fs::read_to_string(&canon).unwrap_or_default();
if existing.trim() != ks.trim() {
if !existing.is_empty() {
// Backups live in a hidden `.bak/` subdir so they never get picked up
// as `<mpn>.<method>.kicad_sym` source variants (which cluttered the
// Source carat). `.vendor` = the ORIGINAL, kept once; `.prev` = the
// immediately-previous canonical, a one-step undo so a later edit can't
// unrecoverably clobber an earlier good one.
let bak = dirp.join(".bak");
let _ = std::fs::create_dir_all(&bak);
let vendor = bak.join(format!("{mpn}.vendor.kicad_sym"));
if !vendor.exists() {
let _ = std::fs::write(&vendor, &existing);
}
let _ = std::fs::write(bak.join(format!("{mpn}.prev.kicad_sym")), &existing);
}
let _ = std::fs::write(&canon, &ks);
}
let render_name = crate::first_symbol_name(&ks).unwrap_or_else(|| mpn.clone());
let frac = crate::render::chip_size_frac(chip_size);
let _ = crate::render::render_into_library(&ks, &render_name, &mpn, dirp, frac);
// Persist the per-symbol prefs so this deliberate look STICKS — on the next
// load/nav-back the sticky default won't override it (saved wins).
if let Some(m) = MODEL.lock().unwrap().as_mut() {
m.chip_size = chip_size.to_string();
m.saved = true;
write_saved_prefs(&dir, json!({
"source": m.source, "pinLayout": m.pin_layout, "grouped": m.grouped,
"refPos": m.ref_pos, "valuePos": m.value_pos, "laserName": m.laser_name,
"threeD": m.three_d, "chipSize": chip_size,
// Custom (dragged / auto-placed) designator positions, KiCad mm Y-up.
"refX": m.ref_x, "refY": m.ref_y, "valueX": m.value_x, "valueY": m.value_y,
}));
}
true
}
/// Remove this chip's per-symbol prefs (info.json → `adom_symbol`) so it goes back
/// to FOLLOWING the sticky default. Backs the kicad_sym change is NOT undone here;
/// this only un-pins the layout/display prefs. Returns true if it had any.
fn unpin_current() -> bool {
let (dir, mpn) = match MODEL.lock().unwrap().as_ref() { Some(m) => (m.dir.clone(), m.mpn.clone()), None => return false };
if dir.is_empty() { return false; }
let dirp = std::path::Path::new(&dir);
let mut had = false;
// 1) Drop this chip's per-symbol prefs block from info.json.
let path = dirp.join("info.json");
if let Ok(s) = std::fs::read_to_string(&path) {
if let Ok(mut root) = serde_json::from_str::<Value>(&s) {
had = root.get("adom_symbol").is_some();
if let Some(obj) = root.as_object_mut() { obj.remove("adom_symbol"); }
if let Ok(out) = serde_json::to_string_pretty(&root) { let _ = std::fs::write(&path, out); }
}
}
// 2) Restore the pristine vendor .kicad_sym if a backup exists — so a label
// that was dragged/auto-placed and SAVED (baked into the canonical file) is
// fully reverted, not just un-pinned. The .vendor backup is written once on
// the first save (save_current), so it's the true original.
let vendor = dirp.join(".bak").join(format!("{mpn}.vendor.kicad_sym"));
if vendor.exists() {
if let Ok(orig) = std::fs::read_to_string(&vendor) {
if std::fs::write(dirp.join(format!("{mpn}.kicad_sym")), orig).is_ok() { had = true; }
}
}
// 3) Clear in-memory: no saved prefs, no custom field overrides.
if let Some(m) = MODEL.lock().unwrap().as_mut() {
m.saved = false;
m.ref_x = None; m.ref_y = None; m.value_x = None; m.value_y = None;
}
had
}
/// Write the given prefs into chips' info.json (bulk "Apply to all"). When `only`
/// is Some, restrict to exactly those MPNs (the filtered/shown set); when None,
/// every chip in the library root. Only touches info.json (no re-render) — the new
/// look applies as each chip loads. Returns how many chips were written.
fn apply_prefs_to_all(prefs: &Value, only: Option<&[String]>) -> usize {
let root = match library_root() { Some(r) => r, None => return 0 };
let mut n = 0;
if let Ok(rd) = std::fs::read_dir(&root) {
for e in rd.flatten() {
if !e.path().is_dir() { continue; }
let name = e.file_name().to_string_lossy().to_string();
if let Some(list) = only { if !list.iter().any(|m| m == &name) { continue; } }
if e.path().join(format!("{name}.kicad_sym")).is_file() {
write_saved_prefs(&e.path().to_string_lossy(), prefs.clone());
n += 1;
}
}
}
n
}
/// Regenerate a chip's 3D assets (outlines + iso, forced) via chip-thumbnailer,
/// then refresh the in-memory 3D assets so the live app shows the fresh outline,
/// and bump REV so the poller re-applies them. chip-thumbnailer resolves the chip
/// via the library root it reads from the SAME `CHIP_FETCHER_LIB` this process
/// inherited. Runs on a background thread (it's multi-second per chip).
fn run_regen(dir: &str, mpn: &str, axis: &str) {
let _ = std::process::Command::new("adom-chip-thumbnailer")
.args(["rerender", mpn, "--kind", "outline"])
.output(); // LIGHT: just the composited vector outline, not the iso family
let assets = load_3d_assets(dir, mpn, axis);
// The user may have switched chips while this ran — only apply the refreshed
// assets if we're STILL on this chip, else we'd corrupt the new chip's view.
let still_current = MODEL.lock().unwrap().as_ref().map(|m| m.mpn == mpn).unwrap_or(false);
if still_current {
if let Some(st) = STATE.lock().unwrap().as_mut() {
st.three_d_assets = assets;
}
REV.fetch_add(1, Ordering::SeqCst);
}
}
/// Load a chip given a `.kicad_sym` path. If it sits in a chip-fetcher library
/// layout (`<root>/<mpn>/<mpn>.kicad_sym`) we load the FULL model (sources + 3D
/// assets) and register the library root for the switcher; otherwise we fall back
/// to a plain render of just that file.
pub fn load_chip_from_file(file: &std::path::Path) -> Result<()> {
let mpn = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let dir = file.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
if !mpn.is_empty()
&& !dir.is_empty()
&& std::path::Path::new(&dir).join(format!("{mpn}.kicad_sym")).is_file()
{
load_from_dir(&dir, &mpn, "auto");
Ok(())
} else {
load_symbol_into_state(file, None)
}
}
/// Regenerate the AppState from the current Model (source + options).
/// Bake the model's custom Reference/Value positions into a kicad_sym, if any.
/// No-op when neither override is set (the discrete refPos/valuePos placement,
/// or the vendor file's own positions, stands). Source-agnostic.
fn apply_field_overrides(ks: String, m: &Model) -> String {
let mut out = ks;
if let (Some(x), Some(y)) = (m.ref_x, m.ref_y) {
out = crate::render::set_field_pos(&out, "Reference", x, y);
}
if let (Some(x), Some(y)) = (m.value_x, m.value_y) {
out = crate::render::set_field_pos(&out, "Value", x, y);
}
out
}
fn regenerate() {
let m = match MODEL.lock().unwrap().clone() {
Some(m) => m,
None => return,
};
let mut sources = Vec::new();
if m.has_ds2sf {
sources.push("auto".to_string());
sources.push("ds2sf".to_string());
}
if m.has_mfr {
sources.push("manufacturer".to_string());
}
for v in &m.variants {
sources.push(v.clone());
}
// Never list the same source twice (a `.manufacturer.` variant is a tagged
// copy of the canonical CSE symbol — same thing, one entry).
let mut seen = std::collections::HashSet::new();
sources.retain(|s| seen.insert(s.clone()));
// Friendly labels per source. The canonical "manufacturer" entry is shown by
// its REAL origin (e.g. "SnapEDA") from info.json.source_of_record / provenance,
// so the user sees where it came from, not a generic word.
let manufacturer = read_file(&m.dir, "info.json")
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("manufacturer").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_default();
// Where the whole library bundle was downloaded (discovery_source) — NOT who
// authored it (content_origin). A TI model pulled from CSE reads "Component
// Search Engine", not "Manufacturer". The altium/fusion format variants came
// from this SAME bundle, so they share this discovery source.
let discovery = read_file(&m.dir, &format!("{}-file-provenance.json", m.mpn))
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get(format!("{}.kicad_sym", m.mpn)).and_then(|e| e.get("discovery_source").or_else(|| e.get("content_origin"))).and_then(|s| s.as_str()).map(String::from))
.or_else(|| {
read_file(&m.dir, "info.json")
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("source_of_record").and_then(|s| s.get("kicad_sym")).and_then(|s| s.as_str()).map(String::from))
})
.unwrap_or_else(|| "cse".to_string());
let discovery_label = crate::provenance::source_label(&discovery, &manufacturer);
let mut source_labels: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for s in &sources {
source_labels.insert(s.clone(), source_label(s, &manufacturer, &discovery_label));
}
// A vendor variant source = a discovered <mpn>.<method>.kicad_sym.
let is_variant = m.variants.iter().any(|v| v == &m.source);
let result: Result<AppState> = (|| {
let mut st = if is_variant {
let ks = read_file(&m.dir, &format!("{}.{}.kicad_sym", m.mpn, m.source))
.ok_or_else(|| anyhow::anyhow!("no {}.{}.kicad_sym", m.mpn, m.source))?;
let rn = crate::first_symbol_name(&ks).unwrap_or_else(|| m.mpn.clone());
build_symbol_state(&apply_field_overrides(ks, &m), &rn, &m.mpn, "", "", "", "")?
} else if m.source == "manufacturer" || (!m.has_ds2sf) {
let ks = read_file(&m.dir, &format!("{}.kicad_sym", m.mpn))
.ok_or_else(|| anyhow::anyhow!("no {}.kicad_sym", m.mpn))?;
let rn = crate::first_symbol_name(&ks).unwrap_or_else(|| m.mpn.clone());
build_symbol_state(&apply_field_overrides(ks, &m), &rn, &m.mpn, "", "", "", "")?
} else {
// auto / ds2sf → generate via our generator from ds2sf pins+groups.
let js = read_file(&m.dir, &format!("{}-symbol.extracted.json", m.mpn))
.ok_or_else(|| anyhow::anyhow!("no ds2sf extraction"))?;
let sym = crate::ds2sf::parse(&js)?;
let opts = crate::ds2sf::LayoutOpts {
pin_layout: m.pin_layout.clone(),
grouped: m.grouped,
ref_pos: m.ref_pos.clone(),
value_pos: m.value_pos.clone(),
};
let (input, pin_desc, group_desc) = crate::ds2sf::build_ic_input(&sym, &opts);
let ks = apply_field_overrides(crate::sym_gen::generate_ic_sym(&input), &m);
let mut st = build_symbol_state(&ks, &input.symbol_name, &m.mpn, &sym.manufacturer, &sym.package, &sym.description, "")?;
// merge ds2sf descriptions onto the parsed pins + groups (for tooltips)
for p in st.pins.iter_mut() {
if let Some(d) = pin_desc.get(&p.name).or_else(|| pin_desc.get(&p.number)) {
p.desc = d.clone();
}
}
for g in st.groups.iter_mut() {
if let Some(d) = group_desc.get(&g.name) {
g.desc = d.clone();
}
}
st
};
st.sources = sources.clone();
st.source = m.source.clone();
st.pin_layout = m.pin_layout.clone();
st.grouped = m.grouped;
st.ref_pos = m.ref_pos.clone();
st.value_pos = m.value_pos.clone();
st.laser_name = m.laser_name;
st.three_d = m.three_d.clone();
st.three_d_assets = load_3d_assets(&m.dir_for_3d, &m.mpn, &m.mpn_axis);
st.source_labels = source_labels.clone();
st.saved_layout = m.saved;
st.chip_size = m.chip_size.clone();
Ok(st)
})();
if let Ok(st) = result {
set_state(st);
}
}
/// Minimal base64 (std-only) for the iso-render data URL.
fn b64(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for c in data.chunks(3) {
let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
out.push(T[(n >> 18 & 63) as usize] as char);
out.push(T[(n >> 12 & 63) as usize] as char);
out.push(if c.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
out.push(if c.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
pub fn run(port: u16) -> Result<()> {
*EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());
*CONSOLE.lock().unwrap() = Some(Vec::new());
let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
eprintln!("[adom-symbol] serving on http://127.0.0.1:{port}");
register_port(port, "adom-symbol", "KiCad symbol viewer live app (AI-drivable)");
for mut req in server.incoming_requests() {
let url = req.url().split('?').next().unwrap_or("/").to_string();
let method = req.method().clone();
let mut body = String::new();
if matches!(method, Method::Post) {
let _ = req.as_reader().read_to_string(&mut body);
}
let resp = route(&method, &url, &body);
let _ = req.respond(resp);
if url == "/shutdown" && matches!(method, Method::Post) {
break;
}
}
unregister_port(port);
Ok(())
}
/// Best-effort HD port registration (app-creator §6a / adom-port-hints).
/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never
/// fails the app if HD is absent (outside Hydrogen Desktop).
fn register_port(port: u16, name: &str, reason: &str) {
let body = format!(
"{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"
);
std::thread::spawn(move || hd_post(&body));
}
fn unregister_port(port: u16) {
hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));
}
fn hd_post(body: &str) {
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {
Some(a) => a,
None => return,
};
if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {
let _ = s.set_write_timeout(Some(Duration::from_millis(800)));
let req = format!(
"POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = s.write_all(req.as_bytes());
}
}
fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
match (method, url) {
(Method::Get, "/") | (Method::Get, "/index.html") =>
html(&APP_HTML.replace("/*__VIEWER_CORE__*/", include_str!("viewer_core.js"))),
(Method::Get, "/favicon.svg") => svg(FAVICON),
(Method::Get, "/state") => json_resp(&state_json()),
(Method::Get, "/svg") => {
let (svg, rev) = match &*STATE.lock().unwrap() {
Some(s) => (s.svg.clone(), REV.load(Ordering::SeqCst)),
None => (String::new(), 0),
};
json_resp(&json!({ "svg": svg, "rev": rev }).to_string())
}
// Standalone interactive viewer for the CURRENT symbol — opened in a new
// tab by the app's "Open interactive viewer" button. Always live: it
// reflects the layout on screen right now.
(Method::Get, "/embed") => match build_current_embed() {
Some(h) => html(&h),
None => json_err(404, "no symbol loaded"),
},
_ if url.starts_with("/3d/") => {
let mode = &url["/3d/".len()..];
let asset = STATE.lock().unwrap().as_ref().and_then(|s| s.three_d_assets.get(mode).cloned()).unwrap_or_default();
json_resp(&json!({ "asset": asset }).to_string())
}
(Method::Post, "/load") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
// Preferred: push raw .kicad_sym; the server renders it via
// service-kicad (mirrors adom-step's /api/load). This is how
// chip-fetcher drives the app — push a file, it renders.
let g = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
// Folder mode (preferred): discover sources + render auto-grouped default.
if !g("dir").is_empty() && !g("mpn").is_empty() {
load_from_dir(&g("dir"), &g("mpn"), &g("source"));
return json_resp("{\"ok\":true}");
}
if let Some(ks) = v.get("kicadSym").and_then(|x| x.as_str()) {
// Render by the file's ACTUAL symbol name; display the
// caller's label (e.g. the MPN). Parses pins + offset.
let render_name = crate::first_symbol_name(ks).unwrap_or_else(|| "symbol".to_string());
let display = { let d = g("symbolName"); if d.is_empty() { render_name.clone() } else { d } };
match build_symbol_state(ks, &render_name, &display, &g("manufacturer"), &g("package"), &g("description"), &g("datasheet")) {
Ok(st) => set_state(st),
Err(e) => return json_err(422, &e.to_string()),
}
} else {
// Pre-rendered: caller already has the SVG (no interactive data).
set_state(AppState {
symbol_name: g("symbolName"),
svg: g("svg"),
manufacturer: g("manufacturer"),
package: g("package"),
description: g("description"),
pin_count: v.get("pinCount").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
..Default::default()
});
}
}
json_resp("{\"ok\":true}")
}
(Method::Get, "/api/projects") => {
// Mirror chip-fetcher's projects (same on-disk store) so the chip
// switcher can offer the same global + per-project filtered lists.
let home = std::env::var("HOME").unwrap_or_default();
let path = std::path::Path::new(&home).join(".config/adom-chip-fetcher/projects.json");
let body = std::fs::read_to_string(&path).unwrap_or_else(|_| "{\"projects\":{}}".to_string());
json_resp(&body)
}
(Method::Get, "/api/chips") => {
let arr: Vec<Value> = list_chips().into_iter().map(|(m, c)| json!({"mpn": m, "current": c})).collect();
let cur = MODEL.lock().unwrap().as_ref().map(|m| m.mpn.clone()).unwrap_or_default();
json_resp(&json!({"chips": arr, "current": cur}).to_string())
}
(Method::Post, "/load-chip") => {
let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let mpn = v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string();
// Deliberately NO auto-save here. The client flushes any unsaved edit
// (POST /save) on the chip you're LEAVING before it calls /load-chip, so
// a deliberate look is still persisted. Calling save_current() on every
// switch re-wrote info.json (+ the kicad_sym, + re-rendered the SVG) for
// every chip you merely BROWSED past — silently pinning the whole library
// to whatever the sticky look happened to be. A chip you only viewed must
// stay unpinned and keep following the sticky default. (Root-caused 2026-06-29.)
match library_root() {
Some(root) if !mpn.is_empty() => {
let dir = std::path::Path::new(&root).join(&mpn);
if dir.join(format!("{mpn}.kicad_sym")).is_file() {
load_from_dir(&dir.to_string_lossy(), &mpn, "auto");
json_resp("{\"ok\":true}")
} else {
json_err(404, "chip not found in library")
}
}
_ => json_err(404, "no library root / mpn"),
}
}
(Method::Post, "/save") => {
let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");
let saved = save_current(chip_size);
// Keep the standalone interactive embed in sync with every Save — but
// OFF the request thread (it re-renders via service-kicad, up to 60s).
// Capture the just-saved symbol so a quick chip-switch can't race it.
if saved {
let dm = { let md = MODEL.lock().unwrap(); md.as_ref().map(|m| (m.dir.clone(), m.mpn.clone())) };
let ks = { let st = STATE.lock().unwrap(); st.as_ref().map(|s| s.kicad_sym.clone()) };
let cap = match (dm, ks) { (Some((dir, mpn)), Some(ks)) => Some((ks, dir, mpn)), _ => None };
if let Some((ks, dir, mpn)) = cap {
std::thread::spawn(move || {
match build_embed_for(&ks, &dir, &mpn) {
Some(h) => {
let p = std::path::Path::new(&dir).join(format!("{mpn}-embed.html"));
if let Err(e) = std::fs::write(&p, h) {
eprintln!("WARN: embed auto-write failed for {mpn}: {e}");
}
}
None => eprintln!("WARN: embed regen skipped for {mpn} (render failed)"),
}
});
}
}
json_resp(&json!({"ok": true, "saved": saved}).to_string())
}
(Method::Post, "/apply-all") => {
// Bulk: push the CURRENT layout/display prefs onto every chip in the
// library (the deliberate "make everything bold" path — overrides even
// per-symbol pins, which is what the user wants for a bulk change).
let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");
let prefs = MODEL.lock().unwrap().as_ref().map(|m| json!({
"source": m.source, "pinLayout": m.pin_layout, "grouped": m.grouped,
"refPos": m.ref_pos, "valuePos": m.value_pos, "laserName": m.laser_name,
"threeD": m.three_d, "chipSize": chip_size,
}));
// Optional explicit scope (the filtered/shown chips). Absent = whole library.
let only: Option<Vec<String>> = v.get("mpns").and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect());
let n = match prefs { Some(p) => apply_prefs_to_all(&p, only.as_deref()), None => 0 };
// The current chip is now "saved" too.
if let Some(m) = MODEL.lock().unwrap().as_mut() { m.saved = true; m.chip_size = chip_size.to_string(); }
json_resp(&json!({"ok": true, "count": n}).to_string())
}
(Method::Post, "/unpin") => {
// Drop this chip's per-symbol prefs + restore the vendor original, then
// re-render so the reverted symbol shows immediately.
let had = unpin_current();
regenerate();
json_resp(&json!({"ok": true, "had": had}).to_string())
}
(Method::Post, "/regen") => {
// Kick chip-thumbnailer to regenerate this chip's 3D outlines + iso in
// a background thread; /state.regen drives the app's spinner.
let started = {
match MODEL.lock().unwrap().as_ref() {
Some(m) if !m.dir.is_empty() && !m.mpn.is_empty() => {
if !REGEN_RUNNING.swap(true, Ordering::SeqCst) {
let (dir, mpn, axis) = (m.dir.clone(), m.mpn.clone(), m.mpn_axis.clone());
std::thread::spawn(move || {
run_regen(&dir, &mpn, &axis);
REGEN_RUNNING.store(false, Ordering::SeqCst);
});
}
true
}
_ => false,
}
};
json_resp(&format!("{{\"ok\":{started}}}"))
}
(Method::Post, "/deliver") => {
let target = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("target").and_then(|t| t.as_str()).map(String::from))
.unwrap_or_default();
let (name, ks) = match &*STATE.lock().unwrap() {
Some(s) => (s.symbol_name.clone(), s.kicad_sym.clone()),
None => (String::new(), String::new()),
};
json_resp(&crate::deliver::deliver(&name, &ks, &target).to_string())
}
(Method::Post, "/relayout") => {
let mut non_3d_change = false;
let mut laser_only = false;
let mut new_3d: Option<String> = None;
if let Ok(v) = serde_json::from_str::<Value>(body) {
if let Some(m) = MODEL.lock().unwrap().as_mut() {
let g = |k: &str| v.get(k).and_then(|x| x.as_str()).map(String::from);
let gn = |k: &str| v.get(k).and_then(|x| x.as_f64());
if let Some(s) = g("source") { m.source = s; non_3d_change = true; }
if let Some(s) = g("pinLayout") { m.pin_layout = s; non_3d_change = true; }
// A discrete refPos/valuePos preset CLEARS any custom (dragged)
// position for that field — the preset wins, as the user just chose it.
if let Some(s) = g("refPos") { m.ref_pos = s; m.ref_x = None; m.ref_y = None; non_3d_change = true; }
if let Some(s) = g("valuePos") { m.value_pos = s; m.value_x = None; m.value_y = None; non_3d_change = true; }
// Custom (manual-drag) positions, KiCad symbol-local mm, Y-up.
if let (Some(x), Some(y)) = (gn("refX"), gn("refY")) { m.ref_x = Some(x); m.ref_y = Some(y); non_3d_change = true; }
if let (Some(x), Some(y)) = (gn("valueX"), gn("valueY")) { m.value_x = Some(x); m.value_y = Some(y); non_3d_change = true; }
if let Some(b) = v.get("grouped").and_then(|x| x.as_bool()) { m.grouped = b; non_3d_change = true; }
if let Some(b) = v.get("laserName").and_then(|x| x.as_bool()) { m.laser_name = b; laser_only = true; }
if let Some(s) = g("threeD") { m.three_d = s.clone(); new_3d = Some(s); }
}
}
if non_3d_change {
regenerate(); // re-renders the symbol (+ refreshes 3D mode + laser flag)
} else if laser_only {
// Laser-name toggle only: client overlay change — update state +
// bump rev so the poller re-fetches, no expensive re-render.
let laser = MODEL.lock().unwrap().as_ref().map(|m| m.laser_name).unwrap_or(false);
if let Some(st) = STATE.lock().unwrap().as_mut() { st.laser_name = laser; }
REV.fetch_add(1, Ordering::SeqCst);
} else if let Some(td) = new_3d {
// 3D-only toggle: update in place, no expensive re-render / no rev bump.
if let Some(st) = STATE.lock().unwrap().as_mut() {
st.three_d = td;
}
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/autofields") => {
// Auto-place the Reference + Value designators CLEAR of the body and
// every pin: Reference centred just above the top extent, Value just
// below the bottom extent. Baked source-agnostically via the override.
let placed = {
let st = STATE.lock().unwrap();
st.as_ref().and_then(|s| {
// Seed the extents from the body rect when present; otherwise
// (polyline-only / discrete symbols with no rect) fall back to
// the pin envelope so auto-place NEVER dead-ends.
let mut max_y = f64::NEG_INFINITY;
let mut min_y = f64::INFINITY;
let mut cx = 0.0;
if let Some(b) = s.body_rect.as_ref() {
max_y = b.y1.max(b.y2); min_y = b.y1.min(b.y2);
cx = (b.x1 + b.x2) / 2.0;
}
if !s.pins.is_empty() {
let (mut sx, mut have_x) = (0.0, false);
for p in &s.pins {
if p.y > max_y { max_y = p.y; }
if p.y < min_y { min_y = p.y; }
sx += p.x; have_x = true;
}
if s.body_rect.is_none() && have_x { cx = sx / s.pins.len() as f64; }
}
if max_y.is_finite() && min_y.is_finite() {
const M: f64 = 2.54; // clearance above/below the outermost feature
Some((cx, max_y + M, cx, min_y - M))
} else { None }
})
};
match placed {
Some((rx, ry, vx, vy)) => {
if let Some(m) = MODEL.lock().unwrap().as_mut() {
m.ref_x = Some(rx); m.ref_y = Some(ry);
m.value_x = Some(vx); m.value_y = Some(vy);
}
regenerate();
json_resp(&json!({"ok": true, "ref": [rx, ry], "value": [vx, vy]}).to_string())
}
None => json_err(400, "no body geometry to auto-place from"),
}
}
(Method::Post, "/eval") => {
let code = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))
.unwrap_or_default();
let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);
EVAL_PENDING.lock().unwrap().push_back((id, code));
json_resp(&json!({ "id": id.to_string() }).to_string())
}
(Method::Get, "/eval/pending") => {
let next = EVAL_PENDING.lock().unwrap().pop_front();
match next {
Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),
None => json_resp("{}"),
}
}
(Method::Get, "/console") => {
let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();
json_resp(&json!({ "lines": log }).to_string())
}
(Method::Post, "/console") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
let line = format!(
"[{}] {}",
v.get("level").and_then(|l| l.as_str()).unwrap_or("log"),
v.get("msg").and_then(|m| m.as_str()).unwrap_or("")
);
if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {
buf.push(line);
let len = buf.len();
if len > 500 {
buf.drain(0..len - 500);
}
}
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),
// /eval/:id and /eval/:id/result
_ if url.starts_with("/eval/") => {
let rest = &url["/eval/".len()..];
if let Some(id_str) = rest.strip_suffix("/result") {
if matches!(method, Method::Post) {
if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {
if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {
map.insert(id, v);
}
}
return json_resp("{\"ok\":true}");
}
}
// GET /eval/:id → poll result
if let Ok(id) = rest.parse::<u64>() {
let map = EVAL_RESULTS.lock().unwrap();
if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {
return json_resp(&json!({ "done": true, "result": r }).to_string());
}
return json_resp("{\"done\":false}");
}
not_found()
}
_ => not_found(),
}
}
fn header(ct: &str) -> Header {
Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(s).with_header(header("text/html; charset=utf-8"))
}
fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(s).with_header(header("image/svg+xml"))
}
fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(s).with_header(header("application/json"))
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(header("application/json"))
}
fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let body = json!({ "ok": false, "error": msg }).to_string();
Response::from_string(body).with_status_code(code).with_header(header("application/json"))
}
/// Load a .kicad_sym from disk, render it via service-kicad, set app state.
pub fn load_symbol_into_state(file: &std::path::Path, name: Option<&str>) -> Result<()> {
let content = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
let symbol_name = name
.map(String::from)
.or_else(|| crate::first_symbol_name(&content))
.unwrap_or_else(|| "symbol".to_string());
let st = build_symbol_state(&content, &symbol_name, &symbol_name, "", "", "", "")?;
set_state(st);
Ok(())
}