app
Adom Schematic
Public Made by Adomby adom
Interactive schematic viewer: your EDA's own render plus per-symbol highlighting and live MPN, stock and price on hover
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
//! Fusion 360 Electronics / EAGLE `.sch` schematic parser + renderer.
//!
//! Fusion Electronics is EAGLE underneath, so one parser serves both. Unlike the
//! KiCad path there is no headless renderer to produce the visual base, so we
//! draw the sheet ourselves in EAGLE's own colour convention (green nets, red
//! symbol outlines, dark-red pins on a light sheet).
//!
//! The upside of self-rendering: we KNOW which primitive belongs to which
//! instance, so every element is tagged `data-cref` directly instead of being
//! inferred by bbox containment the way the KiCad path has to. The frontend's
//! halo highlight then works unchanged.
//!
//! Coordinates: EAGLE is mm, Y-up; we emit Y-down, so every Y is negated.
use crate::sch_parse::{Comp, Sheet};
use roxmltree::{Document, Node};
use std::collections::HashMap;
use std::fmt::Write as _;
pub fn looks_like_eagle(text: &str) -> bool {
let head = &text[..text.len().min(4096)];
head.contains("<eagle") && text.contains("<schematic")
}
fn at(n: Node, k: &str) -> String { n.attribute(k).unwrap_or("").to_string() }
fn num(n: Node, k: &str) -> f64 { n.attribute(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) }
fn rot_of(s: &str) -> (f64, bool) {
let mirror = s.contains('M');
let deg: f64 = s.chars().filter(|c| c.is_ascii_digit() || *c == '.').collect::<String>().parse().unwrap_or(0.0);
(deg, mirror)
}
/// Place a symbol-local point onto the sheet (and flip Y for SVG).
fn place(px: f64, py: f64, ix: f64, iy: f64, deg: f64, mirror: bool) -> (f64, f64) {
let x = if mirror { -px } else { px };
let r = deg.to_radians();
let (s, c) = (r.sin(), r.cos());
(ix + x * c - py * s, -(iy + x * s + py * c))
}
// EAGLE layer colours, as the EDA itself draws them
const C_NET: &str = "#1a9648"; // layer 91 Nets — Fusion green
const C_SYM: &str = "#b11313"; // layer 94 Symbols — Fusion red body outline
const C_PIN: &str = "#1a9648"; // layer 93 Pins — green stub
const C_PINEND: &str = "#e01010"; // unconnected pin-end tick — red
const C_NAME: &str = "#141414"; // layer 95 Names — near-black
const C_VALUE: &str = "#7d828b"; // layer 96 Values — gray
const C_TEXT: &str = "#444444";
const C_INFO: &str = "#8a8f98"; // layer 97/98 Info/Guide — EAGLE draws these gray
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
/// EAGLE layer number -> the colour the EDA draws it, so a symbol's own primitives
/// keep their layer identity (94 body dark-red, 93/91 green, 95/96 name/value,
/// 97/98 info gray) instead of being flattened to one colour.
fn layer_color(n: i32) -> Option<&'static str> {
match n {
91 => Some(C_NET), 93 => Some(C_PIN), 94 => Some(C_SYM),
95 => Some(C_NAME), 96 => Some(C_VALUE), 97 | 98 => Some(C_INFO),
_ => None,
}
}
/// Emit one EAGLE text primitive FAITHFULLY: honour size, alignment and rotation
/// with EAGLE's "keep readable" rule — text whose angle lands in (90,270] is
/// un-flipped and BOTH alignments mirrored, so it never renders upside down. This
/// is what stops smashed NAME/VALUE labels (often rot=R180) from flowing back over
/// their own symbol. `x,y` are already sheet coords (Y-down).
#[allow(clippy::too_many_arguments)]
fn draw_text(out: &mut String, tag: &str, x: f64, y: f64, text: &str, size: f64,
color: &str, rot: f64, align: &str, mirror: bool) {
if text.is_empty() { return; }
// EAGLE align is "<v>-<h>" (bottom-left is the default), or a bare "center".
let a = if align.is_empty() { "bottom-left" } else { align };
let (mut valign, mut halign) = ("bottom", "left");
if a == "center" { valign = "center"; halign = "center"; }
else {
for part in a.split('-') {
match part { "top" | "center" | "bottom" => valign = part, "left" | "right" => halign = part, _ => {} }
}
}
let mut r = ((rot % 360.0) + 360.0) % 360.0;
if mirror {
halign = if halign == "left" { "right" } else if halign == "right" { "left" } else { halign };
r = (360.0 - r) % 360.0;
}
// Readability: 90 < r <= 270 → drop 180deg of glyph rotation, mirror both aligns.
let mut glyph_rot = r;
if r > 90.0 && r <= 270.0 {
glyph_rot = r - 180.0;
halign = if halign == "left" { "right" } else if halign == "right" { "left" } else { halign };
valign = if valign == "top" { "bottom" } else if valign == "bottom" { "top" } else { valign };
}
let anchor = match halign { "center" => "middle", "right" => "end", _ => "start" };
// Y is flipped, so EAGLE "bottom" (text sits above the anchor) is the alphabetic
// baseline; "top" hangs below; "center" is central.
let baseline = match valign { "center" => "central", "top" => "hanging", _ => "auto" };
let transform = if glyph_rot.abs() > 0.01 {
format!(r##" transform="rotate({:.2} {:.3} {:.3})""##, glyph_rot, x, y)
} else { String::new() };
let _ = write!(out, r##"<text{tag} x="{:.3}" y="{:.3}" font-size="{:.3}" fill="{}" text-anchor="{}" dominant-baseline="{}"{}>{}</text>"##,
x, y, size, color, anchor, baseline, transform, esc(text));
}
struct Sym<'a, 'i> { nodes: Vec<Node<'a, 'i>> }
/// Parse + render in one pass. Returns (svg, sheet).
pub fn render_sheet(xml: &str) -> Result<(String, Sheet), String> {
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = Document::parse_with_options(xml, opts).map_err(|e| format!("eagle .sch: {e}"))?;
let root = doc.root_element();
let schem = root.descendants().find(|n| n.has_tag_name("schematic")).ok_or("eagle: no <schematic>")?;
// symbols, keyed "library:symbol"
let mut syms: HashMap<String, Sym> = HashMap::new();
// deviceset -> (gate name -> symbol name), and deviceset+device -> package
let mut gate_sym: HashMap<String, String> = HashMap::new();
let mut dev_pkg: HashMap<String, String> = HashMap::new();
let mut dev_desc: HashMap<String, String> = HashMap::new();
for lib in schem.descendants().filter(|n| n.has_tag_name("library")) {
let lname = at(lib, "name");
for s in lib.descendants().filter(|n| n.has_tag_name("symbol")) {
syms.insert(format!("{}:{}", lname, at(s, "name")), Sym { nodes: s.children().filter(|c| c.is_element()).collect() });
}
for ds in lib.descendants().filter(|n| n.has_tag_name("deviceset")) {
let dsn = at(ds, "name");
if let Some(d) = ds.children().find(|c| c.has_tag_name("description")) {
dev_desc.insert(format!("{}:{}", lname, dsn), d.text().unwrap_or("").trim().to_string());
}
for g in ds.descendants().filter(|n| n.has_tag_name("gate")) {
gate_sym.insert(format!("{}:{}:{}", lname, dsn, at(g, "name")), format!("{}:{}", lname, at(g, "symbol")));
}
for d in ds.descendants().filter(|n| n.has_tag_name("device")) {
dev_pkg.insert(format!("{}:{}:{}", lname, dsn, at(d, "name")), at(d, "package"));
}
}
}
// parts
struct Part { library: String, deviceset: String, device: String, value: String, lcsc: String, datasheet: String }
let mut parts: HashMap<String, Part> = HashMap::new();
for p in schem.descendants().filter(|n| n.has_tag_name("part")) {
let mut lcsc = String::new();
let mut datasheet = String::new();
for a in p.children().filter(|c| c.has_tag_name("attribute")) {
let k = at(a, "name").to_uppercase();
let v = at(a, "value");
if k.contains("LCSC") || k.contains("JLC") { lcsc = v; }
else if k.contains("DATASHEET") { datasheet = v; }
}
parts.insert(at(p, "name"), Part {
library: at(p, "library"), deviceset: at(p, "deviceset"), device: at(p, "device"),
value: at(p, "value"), lcsc, datasheet,
});
}
let mut body = String::new(); // sheet graphics (wires, symbols)
let mut comps: Vec<Comp> = Vec::new();
let mut ext: Vec<(f64, f64)> = Vec::new();
// ── nets: wires, junctions, labels ──────────────────────────────────────
for net in schem.descendants().filter(|n| n.has_tag_name("net")) {
let nname = at(net, "name");
for seg in net.children().filter(|c| c.has_tag_name("segment")) {
for w in seg.children().filter(|c| c.has_tag_name("wire")) {
let (x1, y1) = (num(w, "x1"), -num(w, "y1"));
let (x2, y2) = (num(w, "x2"), -num(w, "y2"));
ext.push((x1, y1)); ext.push((x2, y2));
let _ = write!(body, r##"<line class="adom-wire" data-net="{}" x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="{:.3}" stroke-linecap="round"/>"##,
esc(&nname), x1, y1, x2, y2, C_NET, num(w, "width").max(0.15));
}
for j in seg.children().filter(|c| c.has_tag_name("junction")) {
let _ = write!(body, r##"<circle cx="{:.3}" cy="{:.3}" r="0.5" fill="{}"/>"##, num(j, "x"), -num(j, "y"), C_NET);
}
for l in seg.children().filter(|c| c.has_tag_name("label")) {
let (x, y) = (num(l, "x"), -num(l, "y"));
let size = if num(l, "size") > 0.0 { num(l, "size") } else { 1.778 };
let (deg, _) = rot_of(&at(l, "rot"));
ext.push((x - 12.0, y - 3.0)); ext.push((x + 12.0, y + 3.0));
if at(l, "xref").eq_ignore_ascii_case("yes") {
draw_xref_flag(&mut body, "", x, y, &nname, size, deg);
} else {
draw_text(&mut body, "", x + 0.4, y, &nname, size, C_NET, deg, "bottom-left", false);
}
}
}
}
// ── free graphics on the sheet (frame, notes) ───────────────────────────
for pl in schem.descendants().filter(|n| n.has_tag_name("plain")) {
for n in pl.children().filter(|c| c.is_element()) {
draw_prim(&mut body, n, 0.0, 0.0, 0.0, false, C_TEXT, "", &mut ext, "");
}
}
// ── instances: the actual components ────────────────────────────────────
for inst in schem.descendants().filter(|n| n.has_tag_name("instance")) {
let pname = at(inst, "part");
let (ix, iy) = (num(inst, "x"), num(inst, "y"));
let (deg, mirror) = rot_of(&at(inst, "rot"));
let part = match parts.get(&pname) { Some(p) => p, None => continue };
let skey = format!("{}:{}:{}", part.library, part.deviceset, at(inst, "gate"));
let sym = gate_sym.get(&skey).and_then(|k| syms.get(k));
let mut my: Vec<(f64, f64)> = Vec::new();
let mut pin_count = 0usize;
let cref = esc(&pname);
// The symbol's >NAME / >VALUE placeholders (local x, y, size, align, rot) —
// used to place the label when the instance didn't smash it out explicitly.
let mut name_ph: Option<(f64, f64, f64, String, f64)> = None;
let mut value_ph: Option<(f64, f64, f64, String, f64)> = None;
let ctag = format!(r##" data-cref="{}""##, cref);
let _ = write!(body, r##"<g class="adom-sym" data-cref="{}">"##, cref);
if let Some(sym) = sym {
for n in &sym.nodes {
let n = *n;
if n.has_tag_name("pin") {
pin_count += 1;
let len = match at(n, "length").as_str() { "point" => 0.0, "short" => 2.54, "long" => 7.62, _ => 5.08 };
let (pdeg, _) = rot_of(&at(n, "rot"));
let (x0, y0) = (num(n, "x"), num(n, "y"));
let a = pdeg.to_radians();
let (x1, y1) = (x0 + len * a.cos(), y0 + len * a.sin());
let (sx, sy) = place(x0, y0, ix, iy, deg, mirror);
let (ex2, ey2) = place(x1, y1, ix, iy, deg, mirror);
my.push((sx, sy)); my.push((ex2, ey2));
let _ = write!(body, r##"<line data-cref="{}" x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="0.15"/>"##,
cref, sx, sy, ex2, ey2, C_PIN);
// EAGLE draws the pin junction as a small red mark at the connect point.
let _ = write!(body, r##"<circle data-cref="{}" cx="{:.3}" cy="{:.3}" r="0.35" fill="none" stroke="{}" stroke-width="0.18"/>"##,
cref, sx, sy, C_PINEND);
continue;
}
if n.has_tag_name("text") {
let t = n.text().unwrap_or("").trim().to_string();
let ph = (num(n, "x"), num(n, "y"), if num(n, "size") > 0.0 { num(n, "size") } else { 1.4224 }, at(n, "align"), rot_of(&at(n, "rot")).0);
if t.eq_ignore_ascii_case(">NAME") { name_ph = Some(ph); continue; }
if t.eq_ignore_ascii_case(">VALUE") { value_ph = Some(ph); continue; }
}
draw_prim(&mut body, n, ix, iy, deg, mirror, C_SYM, &cref, &mut my, &pname);
}
}
// NAME / VALUE. Priority: a smashed instance <attribute> (ABSOLUTE coords +
// its own size/align/rot) → the symbol's >NAME/>VALUE placeholder placed by
// the instance → a sensible default. This is what stops labels flowing back
// over their own symbol.
let inst_attr = |want: &str| inst.children().find(|c| c.has_tag_name("attribute") && at(*c, "name").eq_ignore_ascii_case(want));
let (nx, ny) = if let Some(a) = inst_attr("NAME") {
let sz = if num(a, "size") > 0.0 { num(a, "size") } else { 1.778 };
let (x, y) = (num(a, "x"), -num(a, "y"));
draw_text(&mut body, &ctag, x, y, &pname, sz, C_NAME, rot_of(&at(a, "rot")).0, &at(a, "align"), false);
(x, y)
} else if let Some((px, py, sz, al, pr)) = name_ph.clone() {
let (x, y) = place(px, py, ix, iy, deg, mirror);
draw_text(&mut body, &ctag, x, y, &pname, sz, C_NAME, deg + pr, &al, mirror);
(x, y)
} else {
let (x, y) = place(0.0, 2.2, ix, iy, deg, mirror);
draw_text(&mut body, &ctag, x, y, &pname, 1.778, C_NAME, 0.0, "bottom-left", false);
(x, y)
};
let (vx, vy) = if part.value.is_empty() { (nx, ny) } else if let Some(a) = inst_attr("VALUE") {
let sz = if num(a, "size") > 0.0 { num(a, "size") } else { 1.778 };
let (x, y) = (num(a, "x"), -num(a, "y"));
draw_text(&mut body, &ctag, x, y, &part.value, sz, C_VALUE, rot_of(&at(a, "rot")).0, &at(a, "align"), false);
(x, y)
} else if let Some((px, py, sz, al, pr)) = value_ph.clone() {
let (x, y) = place(px, py, ix, iy, deg, mirror);
draw_text(&mut body, &ctag, x, y, &part.value, sz, C_VALUE, deg + pr, &al, mirror);
(x, y)
} else {
let (x, y) = place(0.0, -2.2, ix, iy, deg, mirror);
draw_text(&mut body, &ctag, x, y, &part.value, 1.778, C_VALUE, 0.0, "bottom-left", false);
(x, y)
};
body.push_str("</g>");
my.push((nx - 3.0, ny - 2.0)); my.push((nx + 8.0, ny + 2.0));
my.push((vx - 3.0, vy - 2.0)); my.push((vx + 8.0, vy + 2.0));
if my.is_empty() { my.push((ix - 2.54, -iy - 2.54)); my.push((ix + 2.54, -iy + 2.54)); }
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (x, y) in &my { mnx = mnx.min(*x); mxx = mxx.max(*x); mny = mny.min(*y); mxy = mxy.max(*y); }
ext.extend(my.iter().copied());
let pkgkey = format!("{}:{}:{}", part.library, part.deviceset, part.device);
comps.push(Comp {
reference: pname.clone(),
value: part.value.clone(),
footprint: dev_pkg.get(&pkgkey).cloned().unwrap_or_default(),
lcsc: part.lcsc.clone(),
description: dev_desc.get(&format!("{}:{}", part.library, part.deviceset)).cloned().unwrap_or_default(),
datasheet: part.datasheet.clone(),
x: ix, y: -iy,
bbox: [mnx, mny, mxx, mxy],
pin_count,
regions: vec![[mnx, mny, mxx, mxy]],
});
}
// ── compose ─────────────────────────────────────────────────────────────
if ext.is_empty() { ext.push((0.0, 0.0)); ext.push((100.0, 100.0)); }
let m = 5.0;
let minx = ext.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - m;
let miny = ext.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - m;
let maxx = ext.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + m;
let maxy = ext.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + m;
let (w, h) = (maxx - minx, maxy - miny);
let sheet = Sheet { comps, sub_sheets: Vec::new(), content_bbox: (minx, miny, w, h) };
let mut s = String::new();
let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet" font-family="ui-monospace,Menlo,monospace">"##, minx, miny, w, h);
let _ = write!(s, r##"<rect class="adom-sheet-bg" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#fbfbf6"/>"##, minx, miny, w, h);
let _ = write!(s, r##"<g class="adom-sheet">{}</g>"##, body);
s.push_str(&crate::sch_parse::build_overlay(&sheet));
s.push_str("</svg>");
Ok((s, sheet))
}
/// Draw an EAGLE cross-reference net LABEL as the gray pointed pennant Fusion
/// draws (a rectangle with a triangular point at the net-attachment end). `x,y`
/// are already sheet coords (Y-down); `rot` selects the point direction
/// (R0 right, R90 up, R180 left, R270 down).
fn draw_xref_flag(out: &mut String, tag: &str, x: f64, y: f64, name: &str, size: f64, rot: f64) {
// Fusion draws the net-label text noticeably larger than the plain label size,
// filling the flag; bump it and fit the pennant tightly around it.
let ts = size * 1.4; // flag text size
let w = name.chars().count() as f64 * ts * 0.62 + ts * 0.7; // body width
let h = ts * 1.35; // body height
let pl = h * 0.5; // point length
// Local pennant: attach tip at (0,0) pointing -x (LEFT), body to the RIGHT — so
// an R0 label points left toward its net and its text sits in clear space; R180
// rotates it to point right (e.g. into U$1), matching how Fusion draws them.
let local = [(0.0, 0.0), (pl, -h / 2.0), (pl + w, -h / 2.0), (pl + w, h / 2.0), (pl, h / 2.0)];
// EAGLE rot is CCW in Y-up; on our Y-down sheet that's a CW screen rotation of
// the same magnitude, so screen theta = -rot puts R90 pointing up.
let th = (-rot).to_radians();
let (s, c) = (th.sin(), th.cos());
let mut d = String::new();
for (i, (lx, ly)) in local.iter().enumerate() {
let (rx, ry) = (lx * c - ly * s + x, lx * s + ly * c + y);
let _ = write!(d, "{} {:.3} {:.3} ", if i == 0 { "M" } else { "L" }, rx, ry);
}
d.push('Z');
let _ = write!(out, r##"<path{tag} d="{}" fill="none" stroke="{}" stroke-width="0.18"/>"##, d, C_INFO);
// Net name centred in the body, rotated with the flag.
let (bx, by) = (pl + w / 2.0, 0.0);
let (tx, ty) = (bx * c - by * s + x, bx * s + by * c + y);
let mut r = ((rot % 360.0) + 360.0) % 360.0;
if r > 90.0 && r <= 270.0 { r -= 180.0; }
let transform = if r.abs() > 0.01 { format!(r##" transform="rotate({:.2} {:.3} {:.3})""##, r, tx, ty) } else { String::new() };
let _ = write!(out, r##"<text{tag} x="{:.3}" y="{:.3}" font-size="{:.3}" fill="{}" text-anchor="middle" dominant-baseline="central"{}>{}</text>"##,
tx, ty, ts, C_NAME, transform, esc(name));
}
/// Draw one EAGLE primitive (wire/rect/circle/polygon/text) into `out`.
#[allow(clippy::too_many_arguments)]
fn draw_prim(out: &mut String, n: Node, ix: f64, iy: f64, deg: f64, mirror: bool,
color: &str, cref: &str, ext: &mut Vec<(f64, f64)>, _owner: &str) {
let p = |x: f64, y: f64| place(x, y, ix, iy, deg, mirror);
let tag = if cref.is_empty() { String::new() } else { format!(r##" data-cref="{}""##, cref) };
let layer = num(n, "layer") as i32;
// Each primitive keeps its EAGLE layer colour (94 body red, 97 info gray, …),
// falling back to the caller's colour for unmapped layers.
let lc = layer_color(layer).unwrap_or(color);
match n.tag_name().name() {
"wire" => {
let (x1, y1) = p(num(n, "x1"), num(n, "y1"));
let (x2, y2) = p(num(n, "x2"), num(n, "y2"));
ext.push((x1, y1)); ext.push((x2, y2));
let _ = write!(out, r##"<line{} x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="{:.3}" stroke-linecap="round"/>"##,
tag, x1, y1, x2, y2, lc, num(n, "width").max(0.15));
}
"rectangle" => {
let (x1, y1) = p(num(n, "x1"), num(n, "y1"));
let (x2, y2) = p(num(n, "x2"), num(n, "y2"));
ext.push((x1, y1)); ext.push((x2, y2));
let _ = write!(out, r##"<rect{} x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="{}" stroke-width="0.2"/>"##,
tag, x1.min(x2), y1.min(y2), (x2 - x1).abs(), (y2 - y1).abs(), lc);
}
"circle" => {
let (cx, cy) = p(num(n, "x"), num(n, "y"));
let r = num(n, "radius");
ext.push((cx - r, cy - r)); ext.push((cx + r, cy + r));
let _ = write!(out, r##"<circle{} cx="{:.3}" cy="{:.3}" r="{:.3}" fill="none" stroke="{}" stroke-width="{:.3}"/>"##,
tag, cx, cy, r, lc, num(n, "width").max(0.15));
}
"polygon" | "polygonshape" => {
let pts: Vec<(f64, f64)> = n.children().filter(|c| c.has_tag_name("vertex"))
.map(|v| p(num(v, "x"), num(v, "y"))).collect();
if pts.len() < 2 { return; }
let mut d = String::new();
for (i, (x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{} {:.3} {:.3} ", if i == 0 { "M" } else { "L" }, x, y);
}
d.push('Z');
ext.extend(pts.iter().copied());
// A pour=solid polygon (the mechanical-pin connector shapes on the Info
// layer) fills solidly like Fusion draws it; other polygons stay light.
let opacity = if at(n, "pour") == "solid" { 0.7 } else { 0.25 };
let _ = write!(out, r##"<path{} d="{}" fill="{}" fill-opacity="{}" stroke="{}" stroke-width="0.15"/>"##, tag, d, lc, opacity, lc);
}
"text" => {
let (x, y) = p(num(n, "x"), num(n, "y"));
let t = n.text().unwrap_or("").trim().to_string();
// >NAME / >VALUE placeholders are drawn from the instance instead
if t.is_empty() || t.starts_with('>') { return; }
let size = if num(n, "size") > 0.0 { num(n, "size") } else { 1.4 };
ext.push((x, y - size)); ext.push((x + t.chars().count() as f64 * size * 0.6, y));
draw_text(out, &tag, x, y, &t, size, lc, deg + rot_of(&at(n, "rot")).0, &at(n, "align"), mirror);
}
_ => {}
}
}