app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
//! Fetch a board's AUTHENTIC per-layer SVG from service-kicad (KiCad 10 headless).
//!
//! This is the visual base of the viewer: each layer rendered exactly as the EDA
//! draws it (real Newstroke silk, knockout, theme colours). We request one layer
//! at a time (`?layers=<L>`) so each comes back cleanly in its own theme colour
//! and we can wrap it in our own toggle/flip/dim group. All layers share the
//! board's mm coordinate system, so they align with each other AND with our
//! parsed interaction overlay (same viewBox).
use anyhow::{bail, Result};
use std::time::Duration;
const SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
pub struct KLayer {
pub group: String, // our layer group class suffix
pub side: char, // 'F' | 'B' | 'I' (inner) | 'X' (shared)
pub inner: String, // the KiCad SVG content (between <svg> and </svg>)
}
/// (KiCad layer name, our group class, side). Order here is z-order bottom→top;
/// the frontend re-orders F/B when flipping sides.
// Mask sits BELOW copper (so pads read as copper, not mask-purple).
const LAYERS: &[(&str, &str, char)] = &[
("B.Fab", "fabB", 'B'),
("B.Mask", "maskB", 'B'),
("B.Cu", "copperB", 'B'),
("B.SilkS", "silkB", 'B'),
("F.Mask", "maskF", 'F'),
("F.Cu", "copperF", 'F'),
("F.SilkS", "silkF", 'F'),
("F.Fab", "fabF", 'F'),
("B.CrtYd", "crtydB", 'B'),
("F.CrtYd", "crtydF", 'F'),
("Edge.Cuts", "edge", 'X'),
];
/// Inner copper layers (`In1.Cu`, `In2.Cu`, …) declared in the board's stackup,
/// ordered as they sit physically from the FRONT downwards. A 4-layer board gives
/// `["In1.Cu", "In2.Cu"]`. Returns empty for a plain 2-layer board.
///
/// These are NOT in `LAYERS` because the set is board-specific — a 2/4/6-layer
/// board must not request layers it doesn't have (and a 4-layer board whose pours
/// live only on In1/In2, like the USB3-to-Ethernet molecule, would otherwise show
/// no copper fills at all).
pub fn inner_copper_layers(board: &str) -> Vec<String> {
let re = regex::Regex::new(r#"\((\d+)\s+"(In\d+\.Cu)"#).unwrap();
let mut v: Vec<(u32, String)> = re
.captures_iter(board)
.filter_map(|c| c[1].parse::<u32>().ok().map(|n| (n, c[2].to_string())))
.collect();
v.sort_by_key(|(_, name)| {
name.trim_start_matches("In").trim_end_matches(".Cu").parse::<u32>().unwrap_or(0)
});
v.dedup_by(|a, b| a.1 == b.1);
v.into_iter().map(|(_, name)| name).collect()
}
/// Our group-class suffix for an inner copper layer: `In1.Cu` -> `copperIn1`.
pub fn inner_group(layer: &str) -> String {
format!("copper{}", layer.trim_end_matches(".Cu"))
}
fn base() -> String {
std::env::var("KICAD_SERVICE_API")
.unwrap_or_else(|_| SERVICE_DEFAULT.to_string())
.trim_end_matches('/')
.to_string()
}
/// Fetch every board layer. Errors (or an empty result) let the caller fall back
/// to the self-render path.
pub fn fetch_all(board: &str) -> Result<Vec<KLayer>> {
let base = base();
let mut out = Vec::new();
// Base layers, plus this board's inner copper layers. Inner layers are inserted
// directly after B.Cu in z-order (bottom->top when viewed from the top), i.e.
// B.Cu, In(n) .. In1, F.Cu — deepest first. The frontend re-orders on flip.
let inners = inner_copper_layers(board);
let mut plan: Vec<(String, String, char)> =
LAYERS.iter().map(|(l, g, s)| (l.to_string(), g.to_string(), *s)).collect();
if let Some(at) = plan.iter().position(|(l, _, _)| l == "B.Cu") {
// reverse: the layer nearest the BACK goes lowest in the stack
for layer in inners.iter().rev() {
plan.insert(at + 1, (layer.clone(), inner_group(layer), 'I'));
}
}
for (layer, group, side) in &plan {
let url = format!("{base}/kicad/pcb/export/svg?layers={layer}");
match ureq::post(&url)
.set("Content-Type", "application/octet-stream")
.timeout(Duration::from_secs(30))
.send_bytes(board.as_bytes())
{
Ok(resp) => {
if let Ok(svg) = resp.into_string() {
let mut inner = extract_inner(&svg);
// On copper: make the ZONE POUR translucent (traces/pads stay solid),
// and recolor KiCad's WHITE drill holes (via/pad barrels) to the dark
// board colour so via insides read black, not white.
if group.starts_with("copper") {
inner = dim_pours(&inner);
inner = inner.replace("fill:#FFFFFF", "fill:#0b0e13").replace("fill:#ffffff", "fill:#0b0e13");
}
if inner.contains("<path") || inner.contains("<circle") {
out.push(KLayer { group: group.clone(), side: *side, inner });
}
}
}
Err(e) => eprintln!("[kicad] layer {layer}: {e}"),
}
}
if out.is_empty() {
bail!("service-kicad returned no usable layers");
}
Ok(out)
}
/// Make copper zone POURS translucent while leaving traces (stroked, no fill) and
/// pads (small filled paths) solid. Heuristic: a filled path with many `L`
/// segments is a pour; give it a low fill-opacity via an inline style.
fn dim_pours(inner: &str) -> String {
// KiCad draws each shape as its own `<path style="fill:#…; fill-opacity:1.0000; …">`
// with the polygon as `M x,y\n x,y … Z` (one comma per vertex). A pour has many
// vertices; pads/traces have few. For big FILLED paths, knock the fill down.
//
// THRESHOLD: measured on the USB3-to-Ethernet molecule (KiCad 10) — the largest
// PAD on F.Cu is 28 vertices (U3, a SOT-223 tab), while the In1.Cu GND pour is
// 4182. The old cutoff of 20 sat BELOW the biggest pads, so it dimmed U3's four
// pads to 28% and the magenta F.Mask beneath showed through: they read as
// translucent pink instead of copper red. 200 sits ~7x above the largest pad and
// ~20x below a real pour.
//
// Deliberately NOT keyed on `fill-rule:evenodd` — 152 F.Cu paths carry it on a
// board with zero front pours, so it does not discriminate.
//
// Known limit: a very small copper island zone (< ~200 verts) renders solid
// rather than translucent. That is a cosmetic miss, and far preferable to
// dimming real pads.
const POUR_MIN_VERTS: usize = 200;
let re = regex::Regex::new(r#"<path\b[^>]*>"#).unwrap();
re.replace_all(inner, |c: ®ex::Captures| {
let tag = &c[0];
let is_fill = tag.contains("fill:#");
let verts = tag.matches(',').count(); // ≈ polygon vertices (style has no commas)
if is_fill && verts > POUR_MIN_VERTS {
tag.replacen("fill-opacity:1.0000", "fill-opacity:0.28", 1)
} else {
tag.to_string()
}
})
.into_owned()
}
/// Keep the content between the opening `<svg …>` and the closing `</svg>`.
fn extract_inner(svg: &str) -> String {
let open_end = match svg.find("<svg").and_then(|s| svg[s..].find('>').map(|e| s + e + 1)) {
Some(i) => i,
None => return String::new(),
};
let close = svg.rfind("</svg>").unwrap_or(svg.len());
svg[open_end..close].to_string()
}