This commit is contained in:
jess 2026-04-22 10:13:08 -07:00
commit a8421dab3a
30 changed files with 2869 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
Cargo.lock
*.swp
.DS_Store

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "siphon"
version = "0.0.1"
edition = "2021"
description = "KiCad board data → vector geometry (pure Rust)"
license = "Unlicense"
[dependencies]
kiutils_sexpr = "0.1"
kiutils_kicad = "0.3"
[dev-dependencies]

12
LICENCE Normal file
View File

@ -0,0 +1,12 @@
This is free to use, without conditions.
There is no licence here on purpose. Individuals, students, hobbyists — take what
you need, make it yours, don't think twice. You'd flatter me.
The absence of a licence is deliberate. A licence is a legal surface. Words can be
reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is
harder to exploit than language. If a company wants to use this, the lack of explicit
permission makes it just inconvenient enough to matter.
This won't change the world. But it shifts the balance, even slightly, away from the
system that co-opts open work for closed profit. That's enough for me.

48
examples/demo.rs Normal file
View File

@ -0,0 +1,48 @@
use siphon::{render_to_svg, RenderOptions};
const DEMO: &str = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
(layers (0 "F.Cu" signal) (31 "B.Cu" signal))
(segment (start 10 10) (end 40 10) (width 0.25) (layer "F.Cu") (uuid "1"))
(segment (start 40 10) (end 40 30) (width 0.25) (layer "F.Cu") (uuid "2"))
(segment (start 40 30) (end 10 30) (width 0.25) (layer "B.Cu") (uuid "3"))
(arc (start 10 30) (mid 5 20) (end 10 10) (width 0.25) (layer "F.Cu") (uuid "4"))
(via (at 40 20) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (uuid "5"))
(gr_rect (start 2 2) (end 50 35) (stroke (width 0.1)) (fill none) (layer "Edge.Cuts") (uuid "6"))
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 25 18 0)
(uuid "fp1")
(fp_text reference "R1" (at 0 -1.3 0) (layer "F.Silkscreen")
(effects (font (size 0.8 0.8) (thickness 0.12))))
(fp_text value "10k" (at 0 1.3 0) (layer "F.Fab")
(effects (font (size 0.6 0.6) (thickness 0.1))))
(fp_line (start -0.8 -0.5) (end 0.8 -0.5) (stroke (width 0.1)) (layer "F.Silkscreen"))
(fp_line (start -0.8 0.5) (end 0.8 0.5) (stroke (width 0.1)) (layer "F.Silkscreen"))
(fp_rect (start -1.4 -0.7) (end 1.4 0.7) (stroke (width 0.05)) (fill none) (layer "F.Courtyard"))
(pad "1" smd roundrect (at -0.8 0 0) (size 0.9 0.9)
(layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))
(pad "2" smd roundrect (at 0.8 0 0) (size 0.9 0.9)
(layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))
)
(gr_text "BOARD TITLE"
(at 26 4 0)
(layer "F.Silkscreen")
(effects (font (size 1.5 1.5) (thickness 0.2)))
(uuid "title"))
(footprint "Connector:PinHeader_1x02"
(layer "F.Cu")
(at 15 20 90)
(uuid "fp2")
(fp_circle (center 0 0) (end 0.5 0) (stroke (width 0.1)) (fill none) (layer "F.Silkscreen"))
(pad "1" thru_hole circle (at 0 0 0) (size 1.7 1.7) (drill 1.0)
(layers "F.Cu" "B.Cu" "F.Mask" "B.Mask"))
(pad "2" thru_hole rect (at 0 2.54 0) (size 1.7 1.7) (drill 1.0)
(layers "F.Cu" "B.Cu" "F.Mask" "B.Mask"))
)
)"#;
fn main() {
let svg = render_to_svg(DEMO, &RenderOptions::default()).expect("render");
std::fs::write("/tmp/siphon_demo.svg", &svg).expect("write");
println!("wrote /tmp/siphon_demo.svg ({} bytes)", svg.len());
}

22
examples/lib_lookup.rs Normal file
View File

@ -0,0 +1,22 @@
use siphon::{LibraryResolver, RenderOptions, SvgSink};
use siphon::geom::BBox;
use siphon::render::render as render_board;
fn main() {
let resolver = LibraryResolver::from_global().expect("load global fp-lib-table");
println!("loaded {} library entries", resolver.table.entries.len());
let lib_id = std::env::args().nth(1).unwrap_or_else(|| "Resistor_SMD:R_0603_1608Metric".into());
println!("resolving {lib_id}");
let fp = resolver.load_footprint(&lib_id).expect("load footprint");
println!("footprint with {} primitives, {} pads", fp.primitives.len(), fp.pads.len());
let board = siphon::Board { items: vec![siphon::Item::Footprint(fp)] };
let mut sink = SvgSink::new(BBox::empty());
let bb = render_board(&board, &mut sink, &RenderOptions::default());
sink.bounds = bb;
let out_path = format!("/tmp/siphon_lib_{}.svg", lib_id.replace(':', "_").replace('/', "_"));
std::fs::write(&out_path, sink.finish()).expect("write");
println!("wrote {out_path}");
}

81
examples/render_board.rs Normal file
View File

@ -0,0 +1,81 @@
use std::path::PathBuf;
use std::time::Instant;
use siphon::geom::BBox;
use siphon::render::render as render_board;
use siphon::{render_to_svg, LibraryResolver, RenderOptions, SvgSink};
fn main() {
let path: PathBuf = std::env::args().nth(1)
.expect("usage: render_board <path/to/board.kicad_pcb> [out.svg]")
.into();
let out: PathBuf = std::env::args().nth(2)
.map(Into::into)
.unwrap_or_else(|| {
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
PathBuf::from(format!("/tmp/siphon_{stem}.svg"))
});
let source = std::fs::read_to_string(&path).expect("read board");
let project_dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let _ = LibraryResolver::from_global().and_then(|r| r.with_project(project_dir));
let t0 = Instant::now();
let svg = render_to_svg(&source, &RenderOptions::default()).expect("render");
let dt = t0.elapsed();
let board = siphon::parse::board::parse(&source).expect("parse");
let stats = count_items(&board);
println!(
"render {:?} → {} bytes in {:?}\n items: {} tracks, {} arcs, {} vias, {} gr_*, {} footprints, {} zones ({} fills), {} dims, {} texts",
path.file_name().unwrap(),
svg.len(),
dt,
stats.tracks, stats.arcs, stats.vias, stats.gr, stats.footprints,
stats.zones, stats.zone_fills, stats.dims, stats.texts,
);
std::fs::write(&out, svg).expect("write");
println!("wrote {}", out.display());
let mut sink = SvgSink::new(BBox::empty());
let bb = render_board(&board, &mut sink, &RenderOptions::default());
sink.bounds = bb;
}
#[derive(Default)]
struct Stats {
tracks: usize,
arcs: usize,
vias: usize,
gr: usize,
footprints: usize,
zones: usize,
zone_fills: usize,
dims: usize,
texts: usize,
}
fn count_items(board: &siphon::Board) -> Stats {
use siphon::Item;
let mut s = Stats::default();
for item in &board.items {
match item {
Item::Segment(_) => s.tracks += 1,
Item::Arc(_) => s.arcs += 1,
Item::Via(_) => s.vias += 1,
Item::GrLine(_) | Item::GrArc(_) | Item::GrRect(_) | Item::GrCircle(_) | Item::GrPoly(_) => s.gr += 1,
Item::GrText(_) => s.texts += 1,
Item::Footprint(f) => {
s.footprints += 1;
s.texts += f.texts.len();
}
Item::Zone(z) => {
s.zones += 1;
s.zone_fills += z.filled_polygons.len();
}
Item::Dimension(_) => s.dims += 1,
}
}
s
}

41
src/geom/arc.rs Normal file
View File

@ -0,0 +1,41 @@
use crate::geom::Point;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Arc {
pub start: Point,
pub mid: Point,
pub end: Point,
}
impl Arc {
pub const fn new(start: Point, mid: Point, end: Point) -> Self {
Self { start, mid, end }
}
pub fn centre_and_radius(&self) -> Option<(Point, f64)> {
let (ax, ay) = (self.start.x, self.start.y);
let (bx, by) = (self.mid.x, self.mid.y);
let (cx, cy) = (self.end.x, self.end.y);
let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if d.abs() < f64::EPSILON {
return None;
}
let ax2_ay2 = ax * ax + ay * ay;
let bx2_by2 = bx * bx + by * by;
let cx2_cy2 = cx * cx + cy * cy;
let ux = (ax2_ay2 * (by - cy) + bx2_by2 * (cy - ay) + cx2_cy2 * (ay - by)) / d;
let uy = (ax2_ay2 * (cx - bx) + bx2_by2 * (ax - cx) + cx2_cy2 * (bx - ax)) / d;
let centre = Point::new(ux, uy);
let r = centre.distance_to(self.start);
Some((centre, r))
}
/// Sweep direction from start → end through mid, as +1 (CCW) or -1 (CW).
pub fn sweep_sign(&self) -> f64 {
let (ax, ay) = (self.start.x, self.start.y);
let (bx, by) = (self.mid.x, self.mid.y);
let (cx, cy) = (self.end.x, self.end.y);
let cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
if cross >= 0.0 { 1.0 } else { -1.0 }
}
}

49
src/geom/bbox.rs Normal file
View File

@ -0,0 +1,49 @@
use crate::geom::Point;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BBox {
pub min_x: f64,
pub min_y: f64,
pub max_x: f64,
pub max_y: f64,
}
impl BBox {
pub fn empty() -> Self {
Self {
min_x: f64::INFINITY,
min_y: f64::INFINITY,
max_x: f64::NEG_INFINITY,
max_y: f64::NEG_INFINITY,
}
}
pub fn is_empty(&self) -> bool {
self.min_x > self.max_x || self.min_y > self.max_y
}
pub fn expand_point(&mut self, p: Point) {
if p.x < self.min_x { self.min_x = p.x; }
if p.y < self.min_y { self.min_y = p.y; }
if p.x > self.max_x { self.max_x = p.x; }
if p.y > self.max_y { self.max_y = p.y; }
}
pub fn expand(&mut self, other: BBox) {
if other.is_empty() { return; }
if other.min_x < self.min_x { self.min_x = other.min_x; }
if other.min_y < self.min_y { self.min_y = other.min_y; }
if other.max_x > self.max_x { self.max_x = other.max_x; }
if other.max_y > self.max_y { self.max_y = other.max_y; }
}
pub fn inflate(&mut self, by: f64) {
self.min_x -= by;
self.min_y -= by;
self.max_x += by;
self.max_y += by;
}
pub fn width(&self) -> f64 { self.max_x - self.min_x }
pub fn height(&self) -> f64 { self.max_y - self.min_y }
}

9
src/geom/mod.rs Normal file
View File

@ -0,0 +1,9 @@
pub mod point;
pub mod arc;
pub mod bbox;
pub mod transform;
pub use arc::Arc;
pub use bbox::BBox;
pub use point::Point;
pub use transform::Transform;

21
src/geom/point.rs Normal file
View File

@ -0,0 +1,21 @@
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
impl Point {
pub const fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
pub fn distance_to(&self, other: Point) -> f64 {
let dx = self.x - other.x;
let dy = self.y - other.y;
(dx * dx + dy * dy).sqrt()
}
pub fn midpoint(&self, other: Point) -> Point {
Point::new((self.x + other.x) * 0.5, (self.y + other.y) * 0.5)
}
}

40
src/geom/transform.rs Normal file
View File

@ -0,0 +1,40 @@
use crate::geom::Point;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transform {
pub translate: Point,
pub rotation_rad: f64,
pub mirror_x: bool,
}
impl Transform {
pub const IDENTITY: Transform = Transform {
translate: Point::new(0.0, 0.0),
rotation_rad: 0.0,
mirror_x: false,
};
pub fn at(p: Point, rotation_deg: f64) -> Self {
Self {
translate: p,
rotation_rad: rotation_deg.to_radians(),
mirror_x: false,
}
}
pub fn apply(&self, local: Point) -> Point {
let mut x = local.x;
if self.mirror_x {
x = -x;
}
let (s, c) = self.rotation_rad.sin_cos();
Point::new(
self.translate.x + c * x - s * local.y,
self.translate.y + s * x + c * local.y,
)
}
}
impl Default for Transform {
fn default() -> Self { Self::IDENTITY }
}

39
src/layer.rs Normal file
View File

@ -0,0 +1,39 @@
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LayerName(pub String);
impl LayerName {
pub fn new<S: Into<String>>(s: S) -> Self { Self(s.into()) }
pub fn as_str(&self) -> &str { &self.0 }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Rgba {
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { Self { r, g, b, a } }
pub const fn opaque(r: u8, g: u8, b: u8) -> Self { Self::new(r, g, b, 255) }
}
#[derive(Debug, Clone)]
pub struct Style {
pub stroke: Option<Rgba>,
pub stroke_width_mm: f64,
pub fill: Option<Rgba>,
}
impl Style {
pub const fn stroke_only(colour: Rgba, width_mm: f64) -> Self {
Self { stroke: Some(colour), stroke_width_mm: width_mm, fill: None }
}
pub const fn fill_only(colour: Rgba) -> Self {
Self { stroke: None, stroke_width_mm: 0.0, fill: Some(colour) }
}
pub const fn fill_and_stroke(fill: Rgba, stroke: Rgba, width_mm: f64) -> Self {
Self { stroke: Some(stroke), stroke_width_mm: width_mm, fill: Some(fill) }
}
}

20
src/lib.rs Normal file
View File

@ -0,0 +1,20 @@
pub mod geom;
pub mod layer;
pub mod library;
pub mod parse;
pub mod render;
pub mod sink;
pub use layer::{LayerName, Rgba, Style};
pub use library::{FpLibTable, LibError, LibraryResolver};
pub use parse::{Board, Item};
pub use render::{render, Palette, RenderOptions};
pub use sink::{Sink, SvgSink};
pub fn render_to_svg(source: &str, opts: &RenderOptions) -> Result<String, parse::board::BoardError> {
let board = parse::board::parse(source)?;
let mut sink = SvgSink::new(geom::BBox::empty());
let bb = render::render(&board, &mut sink, opts);
sink.bounds = bb;
Ok(sink.finish())
}

5
src/library/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod table;
pub mod resolver;
pub use resolver::{LibraryResolver, LibError};
pub use table::{FpLibTable, LibEntry};

182
src/library/resolver.rs Normal file
View File

@ -0,0 +1,182 @@
use std::fs;
use std::path::{Path, PathBuf};
use kiutils_sexpr::parse_one;
use crate::library::table::{FpLibTable, TableError};
use crate::parse::footprint::{parse_footprint, Footprint};
use crate::parse::sexpr::*;
#[derive(Debug)]
pub enum LibError {
UnknownNickname(String),
MissingColon(String),
ResolveFailed { uri: String, reason: String },
ReadFailed(std::io::Error),
Parse(kiutils_sexpr::ParseError),
MalformedFootprint,
Table(TableError),
}
impl std::fmt::Display for LibError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownNickname(n) => write!(f, "unknown lib nickname: {n}"),
Self::MissingColon(s) => write!(f, "lib id must be 'Nickname:Name', got: {s}"),
Self::ResolveFailed { uri, reason } => write!(f, "cannot resolve uri {uri}: {reason}"),
Self::ReadFailed(e) => write!(f, "read: {e}"),
Self::Parse(e) => write!(f, "parse: {e}"),
Self::MalformedFootprint => write!(f, "malformed .kicad_mod"),
Self::Table(e) => write!(f, "table: {e}"),
}
}
}
impl std::error::Error for LibError {}
impl From<TableError> for LibError {
fn from(e: TableError) -> Self { Self::Table(e) }
}
pub struct LibraryResolver {
pub table: FpLibTable,
pub env: Vec<(String, String)>,
}
impl LibraryResolver {
pub fn new(table: FpLibTable) -> Self {
Self { table, env: default_env() }
}
pub fn from_global() -> Result<Self, LibError> {
let path = crate::library::table::global_table_path()
.ok_or_else(|| LibError::Table(TableError::Malformed))?;
let mut resolver = Self::new(FpLibTable::load(&path)?);
resolver.flatten_nested_tables();
Ok(resolver)
}
pub fn with_project(mut self, project_dir: &Path) -> Result<Self, LibError> {
if let Some(p) = crate::library::table::project_table_path(project_dir) {
let mut project = FpLibTable::load(&p)?;
let nested: Vec<_> = project.entries.iter()
.filter(|e| e.lib_type == "Table")
.cloned()
.collect();
for entry in nested {
flatten_one(&mut project, &entry, &self.env);
}
self.table = project.merge(self.table);
}
Ok(self)
}
fn flatten_nested_tables(&mut self) {
let nested: Vec<_> = self.table.entries.iter()
.filter(|e| e.lib_type == "Table")
.cloned()
.collect();
for entry in nested {
flatten_one(&mut self.table, &entry, &self.env);
}
}
pub fn set_env(&mut self, key: impl Into<String>, value: impl Into<String>) {
let key = key.into();
self.env.retain(|(k, _)| k != &key);
self.env.push((key, value.into()));
}
pub fn resolve_dir(&self, nickname: &str) -> Result<PathBuf, LibError> {
let entry = self.table.find(nickname)
.ok_or_else(|| LibError::UnknownNickname(nickname.to_string()))?;
let expanded = expand(&entry.uri, &self.env)
.ok_or_else(|| LibError::ResolveFailed {
uri: entry.uri.clone(),
reason: "unresolved env var".into(),
})?;
Ok(PathBuf::from(expanded))
}
pub fn load_footprint(&self, lib_id: &str) -> Result<Footprint, LibError> {
let (nick, name) = split_id(lib_id)?;
let dir = self.resolve_dir(nick)?;
let path = dir.join(format!("{name}.kicad_mod"));
let source = fs::read_to_string(&path).map_err(LibError::ReadFailed)?;
parse_kicad_mod(&source)
}
}
fn split_id(lib_id: &str) -> Result<(&str, &str), LibError> {
let (a, b) = lib_id.split_once(':')
.ok_or_else(|| LibError::MissingColon(lib_id.to_string()))?;
Ok((a, b))
}
pub fn parse_kicad_mod(source: &str) -> Result<Footprint, LibError> {
let doc = parse_one(source).map_err(LibError::Parse)?;
let root = doc.nodes.first().ok_or(LibError::MalformedFootprint)?;
if head(root) != Some("footprint") && head(root) != Some("module") {
return Err(LibError::MalformedFootprint);
}
parse_footprint(root).ok_or(LibError::MalformedFootprint)
}
fn expand(src: &str, env: &[(String, String)]) -> Option<String> {
let mut out = String::with_capacity(src.len());
let mut i = 0;
let bytes = src.as_bytes();
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
let close = src[i + 2..].find('}')?;
let key = &src[i + 2..i + 2 + close];
let value = env_lookup(key, env)?;
out.push_str(&value);
i += 2 + close + 1;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
Some(out)
}
fn env_lookup(key: &str, env: &[(String, String)]) -> Option<String> {
if let Some((_, v)) = env.iter().find(|(k, _)| k == key) {
return Some(v.clone());
}
std::env::var(key).ok()
}
fn flatten_one(into: &mut FpLibTable, table_entry: &crate::library::table::LibEntry, env: &[(String, String)]) {
let Some(resolved) = expand(&table_entry.uri, env) else { return; };
let path = Path::new(&resolved);
if !path.exists() { return; }
let Ok(inner) = FpLibTable::load(path) else { return; };
for entry in inner.entries {
if entry.lib_type == "Table" { continue; }
if into.find(&entry.name).is_none() {
into.entries.push(entry);
}
}
}
fn default_env() -> Vec<(String, String)> {
let mut out = Vec::new();
let add = |out: &mut Vec<(String, String)>, k: &str, v: String| {
if Path::new(&v).exists() {
out.push((k.to_string(), v));
}
};
let home = std::env::var("HOME").unwrap_or_default();
add(&mut out, "KICAD10_FOOTPRINT_DIR",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints".into());
add(&mut out, "KICAD10_SYMBOL_DIR",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols".into());
add(&mut out, "KICAD10_3DMODEL_DIR",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/3dmodels".into());
add(&mut out, "KICAD10_TEMPLATE_DIR",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/template".into());
add(&mut out, "KICAD10_3RD_PARTY", format!("{home}/Documents/KiCad/10.0/3rdparty"));
out
}

101
src/library/table.rs Normal file
View File

@ -0,0 +1,101 @@
use std::fs;
use std::path::{Path, PathBuf};
use kiutils_sexpr::parse_one;
use crate::parse::sexpr::*;
#[derive(Debug, Clone)]
pub struct LibEntry {
pub name: String,
pub lib_type: String,
pub uri: String,
pub descr: String,
}
#[derive(Debug, Clone, Default)]
pub struct FpLibTable {
pub entries: Vec<LibEntry>,
}
impl FpLibTable {
pub fn parse(source: &str) -> Result<Self, TableError> {
let doc = parse_one(source).map_err(TableError::Parse)?;
let root = doc.nodes.first().ok_or(TableError::Malformed)?;
if head(root) != Some("fp_lib_table") {
return Err(TableError::Malformed);
}
let mut entries = Vec::new();
for lib in find_lists(root, "lib") {
let name = find_list(lib, "name").and_then(|n| arg_str(n, 0)).unwrap_or("").to_string();
let lib_type = find_list(lib, "type").and_then(|n| arg_str(n, 0)).unwrap_or("").to_string();
let uri = find_list(lib, "uri").and_then(|n| arg_str(n, 0)).unwrap_or("").to_string();
let descr = find_list(lib, "descr").and_then(|n| arg_str(n, 0)).unwrap_or("").to_string();
if name.is_empty() || uri.is_empty() { continue; }
entries.push(LibEntry { name, lib_type, uri, descr });
}
Ok(FpLibTable { entries })
}
pub fn load(path: &Path) -> Result<Self, TableError> {
let s = fs::read_to_string(path).map_err(TableError::Io)?;
Self::parse(&s)
}
pub fn find(&self, nickname: &str) -> Option<&LibEntry> {
self.entries.iter().find(|e| e.name == nickname)
}
pub fn merge(mut self, other: FpLibTable) -> FpLibTable {
for entry in other.entries {
if self.find(&entry.name).is_none() {
self.entries.push(entry);
}
}
self
}
}
pub fn global_table_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("KICAD10_CONFIG_DIR") {
let pb = PathBuf::from(p).join("fp-lib-table");
if pb.exists() { return Some(pb); }
}
let home = std::env::var("HOME").ok()?;
let candidates = [
format!("{home}/Library/Preferences/kicad/10.0/fp-lib-table"),
format!("{home}/Library/Preferences/kicad/9.0/fp-lib-table"),
format!("{home}/Library/Preferences/kicad/8.0/fp-lib-table"),
format!("{home}/.config/kicad/10.0/fp-lib-table"),
format!("{home}/.config/kicad/9.0/fp-lib-table"),
];
for c in &candidates {
let p = PathBuf::from(c);
if p.exists() { return Some(p); }
}
None
}
pub fn project_table_path(project_dir: &Path) -> Option<PathBuf> {
let p = project_dir.join("fp-lib-table");
if p.exists() { Some(p) } else { None }
}
#[derive(Debug)]
pub enum TableError {
Parse(kiutils_sexpr::ParseError),
Io(std::io::Error),
Malformed,
}
impl std::fmt::Display for TableError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "parse: {e}"),
Self::Io(e) => write!(f, "io: {e}"),
Self::Malformed => write!(f, "malformed fp-lib-table"),
}
}
}
impl std::error::Error for TableError {}

316
src/parse/board.rs Normal file
View File

@ -0,0 +1,316 @@
use kiutils_sexpr::{parse_one, Node, ParseError};
use crate::geom::Point;
use crate::parse::dimension::{parse_dimension, Dimension};
use crate::parse::footprint::{parse_footprint, Footprint};
use crate::parse::sexpr::*;
use crate::parse::text::{parse_gr_text, GrText};
use crate::parse::zone::{parse_zone, Zone};
#[derive(Debug)]
pub enum BoardError {
Parse(ParseError),
NotABoard,
Malformed(&'static str),
}
impl std::fmt::Display for BoardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "parse: {e}"),
Self::NotABoard => write!(f, "not a kicad_pcb s-expression"),
Self::Malformed(s) => write!(f, "malformed board: {s}"),
}
}
}
impl std::error::Error for BoardError {}
impl From<ParseError> for BoardError {
fn from(e: ParseError) -> Self { Self::Parse(e) }
}
#[derive(Debug, Clone)]
pub struct Board {
pub items: Vec<Item>,
}
#[derive(Debug, Clone)]
pub enum Item {
Segment(Segment),
Arc(ArcItem),
Via(Via),
GrLine(GrLine),
GrArc(GrArc),
GrRect(GrRect),
GrCircle(GrCircle),
GrPoly(GrPoly),
GrText(GrText),
Footprint(Footprint),
Zone(Zone),
Dimension(Dimension),
}
#[derive(Debug, Clone)]
pub struct Segment {
pub start: Point,
pub end: Point,
pub width_mm: f64,
pub layer: String,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ArcItem {
pub start: Point,
pub mid: Point,
pub end: Point,
pub width_mm: f64,
pub layer: String,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Via {
pub at: Point,
pub size_mm: f64,
pub drill_mm: f64,
pub layers: Vec<String>,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GrLine {
pub start: Point,
pub end: Point,
pub width_mm: f64,
pub layer: String,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GrArc {
pub start: Point,
pub mid: Point,
pub end: Point,
pub width_mm: f64,
pub layer: String,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GrRect {
pub start: Point,
pub end: Point,
pub width_mm: f64,
pub fill: bool,
pub layer: String,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GrCircle {
pub centre: Point,
pub end: Point,
pub width_mm: f64,
pub fill: bool,
pub layer: String,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GrPoly {
pub points: Vec<Point>,
pub width_mm: f64,
pub fill: bool,
pub layer: String,
pub uuid: Option<String>,
}
pub fn parse(source: &str) -> Result<Board, BoardError> {
let doc = parse_one(source)?;
let root = doc.nodes.first().ok_or(BoardError::NotABoard)?;
if head(root) != Some("kicad_pcb") {
return Err(BoardError::NotABoard);
}
let mut items = Vec::new();
for child in children(root).iter().skip(1) {
if let Some(item) = parse_item(child) {
items.push(item);
}
}
Ok(Board { items })
}
fn parse_item(node: &Node) -> Option<Item> {
match head(node)? {
"segment" => parse_segment(node).map(Item::Segment),
"arc" => parse_arc(node).map(Item::Arc),
"via" => parse_via(node).map(Item::Via),
"gr_line" => parse_gr_line(node).map(Item::GrLine),
"gr_arc" => parse_gr_arc(node).map(Item::GrArc),
"gr_rect" => parse_gr_rect(node).map(Item::GrRect),
"gr_circle" => parse_gr_circle(node).map(Item::GrCircle),
"gr_poly" => parse_gr_poly(node).map(Item::GrPoly),
"gr_text" => parse_gr_text(node).map(Item::GrText),
"footprint" => parse_footprint(node).map(Item::Footprint),
"zone" => parse_zone(node).map(Item::Zone),
"dimension" => parse_dimension(node).map(Item::Dimension),
_ => None,
}
}
fn parse_segment(node: &Node) -> Option<Segment> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
let width = find_list(node, "width").and_then(|n| arg_f64(n, 0)).unwrap_or(0.2);
let layer = layer_str(node)?;
Some(Segment {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: width,
layer,
uuid: uuid_str(node),
})
}
fn parse_arc(node: &Node) -> Option<ArcItem> {
let (sx, sy) = xy_from(node, "start")?;
let (mx, my) = xy_from(node, "mid")?;
let (ex, ey) = xy_from(node, "end")?;
let width = find_list(node, "width").and_then(|n| arg_f64(n, 0)).unwrap_or(0.2);
let layer = layer_str(node)?;
Some(ArcItem {
start: Point::new(sx, sy),
mid: Point::new(mx, my),
end: Point::new(ex, ey),
width_mm: width,
layer,
uuid: uuid_str(node),
})
}
fn parse_via(node: &Node) -> Option<Via> {
let (ax, ay) = xy_from(node, "at")?;
let size = find_list(node, "size").and_then(|n| arg_f64(n, 0)).unwrap_or(0.8);
let drill = find_list(node, "drill").and_then(|n| arg_f64(n, 0)).unwrap_or(0.4);
let layers = find_list(node, "layers")
.map(|l| {
children(l)
.iter()
.skip(1)
.filter_map(symbol)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Some(Via {
at: Point::new(ax, ay),
size_mm: size,
drill_mm: drill,
layers,
uuid: uuid_str(node),
})
}
fn parse_gr_line(node: &Node) -> Option<GrLine> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node)?;
Some(GrLine {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
layer,
uuid: uuid_str(node),
})
}
fn parse_gr_arc(node: &Node) -> Option<GrArc> {
let (sx, sy) = xy_from(node, "start")?;
let (mx, my) = xy_from(node, "mid")?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node)?;
Some(GrArc {
start: Point::new(sx, sy),
mid: Point::new(mx, my),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
layer,
uuid: uuid_str(node),
})
}
fn parse_gr_rect(node: &Node) -> Option<GrRect> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node)?;
Some(GrRect {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
fill: has_fill_solid(node),
layer,
uuid: uuid_str(node),
})
}
fn parse_gr_circle(node: &Node) -> Option<GrCircle> {
let (cx, cy) = xy_from(node, "center")
.or_else(|| xy_from(node, "centre"))?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node)?;
Some(GrCircle {
centre: Point::new(cx, cy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
fill: has_fill_solid(node),
layer,
uuid: uuid_str(node),
})
}
fn parse_gr_poly(node: &Node) -> Option<GrPoly> {
let pts = find_list(node, "pts")?;
let mut points = Vec::new();
for xy in find_lists(pts, "xy") {
if let (Some(x), Some(y)) = (arg_f64(xy, 0), arg_f64(xy, 1)) {
points.push(Point::new(x, y));
}
}
if points.is_empty() { return None; }
let layer = layer_str(node)?;
Some(GrPoly {
points,
width_mm: stroke_width(node).unwrap_or(0.1),
fill: has_fill_solid(node),
layer,
uuid: uuid_str(node),
})
}
fn stroke_width(node: &Node) -> Option<f64> {
if let Some(stroke) = find_list(node, "stroke") {
if let Some(w) = find_list(stroke, "width") {
return arg_f64(w, 0);
}
}
find_list(node, "width").and_then(|n| arg_f64(n, 0))
}
fn layer_str(node: &Node) -> Option<String> {
find_list(node, "layer").and_then(|n| arg_str(n, 0)).map(str::to_string)
}
fn uuid_str(node: &Node) -> Option<String> {
find_list(node, "uuid").and_then(|n| arg_str(n, 0)).map(str::to_string)
}
fn has_fill_solid(node: &Node) -> bool {
if let Some(fill) = find_list(node, "fill") {
if let Some(v) = arg_str(fill, 0) {
return matches!(v, "solid" | "yes");
}
}
false
}

76
src/parse/dimension.rs Normal file
View File

@ -0,0 +1,76 @@
use kiutils_sexpr::Node;
use crate::geom::Point;
use crate::parse::sexpr::*;
use crate::parse::text::{parse_gr_text, GrText};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DimensionKind {
Aligned,
Orthogonal,
Leader,
Center,
Radial,
Unknown,
}
#[derive(Debug, Clone)]
pub struct Dimension {
pub kind: DimensionKind,
pub layer: String,
pub uuid: Option<String>,
pub points: Vec<Point>,
pub height: f64,
pub arrow_length_mm: f64,
pub thickness_mm: f64,
pub extension_height: f64,
pub extension_offset: f64,
pub text: Option<GrText>,
}
pub fn parse_dimension(node: &Node) -> Option<Dimension> {
if head(node) != Some("dimension") { return None; }
let kind_tok = find_list(node, "type")
.and_then(|n| arg_str(n, 0))
.unwrap_or("aligned");
let kind = match kind_tok {
"aligned" => DimensionKind::Aligned,
"orthogonal" => DimensionKind::Orthogonal,
"leader" => DimensionKind::Leader,
"center" => DimensionKind::Center,
"radial" => DimensionKind::Radial,
_ => DimensionKind::Unknown,
};
let layer = find_list(node, "layer")
.and_then(|n| arg_str(n, 0))
.unwrap_or("Dwgs.User")
.to_string();
let uuid = find_list(node, "uuid").and_then(|n| arg_str(n, 0)).map(str::to_string);
let points = find_list(node, "pts")
.map(|pts| {
children(pts).iter().skip(1).filter_map(|c| {
if head(c) == Some("xy") {
Some(Point::new(arg_f64(c, 0)?, arg_f64(c, 1)?))
} else { None }
}).collect()
})
.unwrap_or_default();
let height = find_list(node, "height").and_then(|n| arg_f64(n, 0)).unwrap_or(0.0);
let style = find_list(node, "style");
let arrow_length_mm = style.and_then(|s| find_list(s, "arrow_length")).and_then(|n| arg_f64(n, 0)).unwrap_or(1.27);
let thickness_mm = style.and_then(|s| find_list(s, "thickness")).and_then(|n| arg_f64(n, 0)).unwrap_or(0.1);
let extension_height = style.and_then(|s| find_list(s, "extension_height")).and_then(|n| arg_f64(n, 0)).unwrap_or(0.58);
let extension_offset = style.and_then(|s| find_list(s, "extension_offset")).and_then(|n| arg_f64(n, 0)).unwrap_or(0.5);
let text = find_list(node, "gr_text").and_then(parse_gr_text);
Some(Dimension {
kind, layer, uuid, points, height,
arrow_length_mm, thickness_mm,
extension_height, extension_offset,
text,
})
}

394
src/parse/footprint.rs Normal file
View File

@ -0,0 +1,394 @@
use kiutils_sexpr::Node;
use crate::geom::Point;
use crate::parse::sexpr::*;
use crate::parse::text::{parse_fp_text, FpText};
#[derive(Debug, Clone)]
pub struct Footprint {
pub library_id: String,
pub at: Point,
pub rotation_deg: f64,
pub reference_layer: String,
pub uuid: Option<String>,
pub primitives: Vec<FpPrimitive>,
pub pads: Vec<Pad>,
pub texts: Vec<FpText>,
}
#[derive(Debug, Clone)]
pub enum FpPrimitive {
Line { start: Point, end: Point, width_mm: f64, layer: String },
Arc { start: Point, mid: Point, end: Point, width_mm: f64, layer: String },
Circle { centre: Point, end: Point, width_mm: f64, fill: bool, layer: String },
Rect { start: Point, end: Point, width_mm: f64, fill: bool, layer: String },
Poly { points: Vec<Point>, width_mm: f64, fill: bool, layer: String },
}
#[derive(Debug, Clone)]
pub struct Pad {
pub number: String,
pub kind: PadKind,
pub shape: PadShape,
pub at: Point,
pub rotation_deg: f64,
pub size: (f64, f64),
pub drill: Option<Drill>,
pub layers: Vec<String>,
pub anchor_shape: AnchorShape,
pub primitives: Vec<FpPrimitive>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnchorShape {
#[default]
Rect,
Circle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PadKind {
Smd,
ThruHole,
Connect,
NpThruHole,
}
#[derive(Debug, Clone)]
pub enum PadShape {
Circle,
Rect,
Roundrect { radius_ratio: f64 },
Oval,
Trapezoid { delta: (f64, f64) },
Custom,
}
#[derive(Debug, Clone, Copy)]
pub struct Drill {
pub diameter: f64,
pub oval: Option<f64>,
pub offset: Option<(f64, f64)>,
}
pub fn parse_footprint(node: &Node) -> Option<Footprint> {
if head(node) != Some("footprint") { return None; }
let library_id = arg_str(node, 0).unwrap_or("").to_string();
let (ax, ay, rot) = read_at(node);
let reference_layer = layer_str(node).unwrap_or_else(|| "F.Cu".to_string());
let uuid = uuid_str(node);
let mut primitives = Vec::new();
let mut pads = Vec::new();
let mut texts = Vec::new();
for child in children(node).iter().skip(1) {
match head(child) {
Some("fp_line") => if let Some(p) = parse_fp_line(child) { primitives.push(p); }
Some("fp_arc") => if let Some(p) = parse_fp_arc(child) { primitives.push(p); }
Some("fp_circle") => if let Some(p) = parse_fp_circle(child) { primitives.push(p); }
Some("fp_rect") => if let Some(p) = parse_fp_rect(child) { primitives.push(p); }
Some("fp_poly") => if let Some(p) = parse_fp_poly(child) { primitives.push(p); }
Some("pad") => if let Some(p) = parse_pad(child) { pads.push(p); }
Some("fp_text") => if let Some(t) = parse_fp_text(child) { texts.push(t); }
Some("property") => if let Some(t) = crate::parse::text::parse_property(child) { texts.push(t); }
_ => {}
}
}
Some(Footprint {
library_id,
at: Point::new(ax, ay),
rotation_deg: rot,
reference_layer,
uuid,
primitives,
pads,
texts,
})
}
fn read_at(node: &Node) -> (f64, f64, f64) {
let Some(at) = find_list(node, "at") else { return (0.0, 0.0, 0.0); };
let x = arg_f64(at, 0).unwrap_or(0.0);
let y = arg_f64(at, 1).unwrap_or(0.0);
let r = arg_f64(at, 2).unwrap_or(0.0);
(x, y, r)
}
fn parse_fp_line(node: &Node) -> Option<FpPrimitive> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node).unwrap_or_default();
Some(FpPrimitive::Line {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
layer,
})
}
fn parse_fp_arc(node: &Node) -> Option<FpPrimitive> {
let (sx, sy) = xy_from(node, "start")?;
let (mx, my) = xy_from(node, "mid")?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node).unwrap_or_default();
Some(FpPrimitive::Arc {
start: Point::new(sx, sy),
mid: Point::new(mx, my),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
layer,
})
}
fn parse_fp_circle(node: &Node) -> Option<FpPrimitive> {
let (cx, cy) = xy_from(node, "center").or_else(|| xy_from(node, "centre"))?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node).unwrap_or_default();
Some(FpPrimitive::Circle {
centre: Point::new(cx, cy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
fill: has_fill_solid(node),
layer,
})
}
fn parse_fp_rect(node: &Node) -> Option<FpPrimitive> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
let layer = layer_str(node).unwrap_or_default();
Some(FpPrimitive::Rect {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
fill: has_fill_solid(node),
layer,
})
}
fn parse_fp_poly(node: &Node) -> Option<FpPrimitive> {
let pts = find_list(node, "pts")?;
let mut points = Vec::new();
for xy in find_lists(pts, "xy") {
if let (Some(x), Some(y)) = (arg_f64(xy, 0), arg_f64(xy, 1)) {
points.push(Point::new(x, y));
}
}
if points.is_empty() { return None; }
let layer = layer_str(node).unwrap_or_default();
Some(FpPrimitive::Poly {
points,
width_mm: stroke_width(node).unwrap_or(0.1),
fill: has_fill_solid(node),
layer,
})
}
fn parse_pad(node: &Node) -> Option<Pad> {
let number = arg_str(node, 0)?.to_string();
let kind = match arg_str(node, 1)? {
"smd" => PadKind::Smd,
"thru_hole" => PadKind::ThruHole,
"connect" => PadKind::Connect,
"np_thru_hole" => PadKind::NpThruHole,
_ => return None,
};
let shape_tok = arg_str(node, 2)?;
let shape = match shape_tok {
"circle" => PadShape::Circle,
"rect" => PadShape::Rect,
"roundrect" => {
let rr = find_list(node, "roundrect_rratio")
.and_then(|n| arg_f64(n, 0))
.unwrap_or(0.25);
PadShape::Roundrect { radius_ratio: rr }
}
"oval" => PadShape::Oval,
"trapezoid" => {
let rd = find_list(node, "rect_delta");
let delta = match rd {
Some(l) => (arg_f64(l, 0).unwrap_or(0.0), arg_f64(l, 1).unwrap_or(0.0)),
None => (0.0, 0.0),
};
PadShape::Trapezoid { delta }
}
"custom" => PadShape::Custom,
_ => return None,
};
let (ax, ay, rot) = read_at(node);
let size = find_list(node, "size")
.map(|s| (arg_f64(s, 0).unwrap_or(0.0), arg_f64(s, 1).unwrap_or(0.0)))
.unwrap_or((0.0, 0.0));
let drill = parse_drill(node);
let layers = find_list(node, "layers")
.map(|l| {
children(l)
.iter()
.skip(1)
.filter_map(symbol)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let anchor_shape = find_list(node, "options")
.and_then(|o| find_list(o, "anchor"))
.and_then(|a| arg_str(a, 0))
.map(|s| if s == "circle" { AnchorShape::Circle } else { AnchorShape::Rect })
.unwrap_or(AnchorShape::Rect);
let primitives = matches!(shape, PadShape::Custom)
.then(|| parse_pad_primitives(node))
.unwrap_or_default();
Some(Pad {
number,
kind,
shape,
at: Point::new(ax, ay),
rotation_deg: rot,
size,
drill,
layers,
anchor_shape,
primitives,
})
}
fn parse_pad_primitives(pad_node: &Node) -> Vec<FpPrimitive> {
let Some(block) = find_list(pad_node, "primitives") else { return Vec::new(); };
let mut out = Vec::new();
for child in children(block).iter().skip(1) {
let prim = match head(child) {
Some("gr_line") => parse_pad_line(child),
Some("gr_arc") => parse_pad_arc(child),
Some("gr_circle") => parse_pad_circle(child),
Some("gr_rect") => parse_pad_rect(child),
Some("gr_poly") => parse_pad_poly(child),
_ => None,
};
if let Some(p) = prim { out.push(p); }
}
out
}
fn parse_pad_line(node: &Node) -> Option<FpPrimitive> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
Some(FpPrimitive::Line {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
layer: String::new(),
})
}
fn parse_pad_arc(node: &Node) -> Option<FpPrimitive> {
let (sx, sy) = xy_from(node, "start")?;
let (mx, my) = xy_from(node, "mid")?;
let (ex, ey) = xy_from(node, "end")?;
Some(FpPrimitive::Arc {
start: Point::new(sx, sy),
mid: Point::new(mx, my),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
layer: String::new(),
})
}
fn parse_pad_circle(node: &Node) -> Option<FpPrimitive> {
let (cx, cy) = xy_from(node, "center").or_else(|| xy_from(node, "centre"))?;
let (ex, ey) = xy_from(node, "end")?;
Some(FpPrimitive::Circle {
centre: Point::new(cx, cy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
fill: true,
layer: String::new(),
})
}
fn parse_pad_rect(node: &Node) -> Option<FpPrimitive> {
let (sx, sy) = xy_from(node, "start")?;
let (ex, ey) = xy_from(node, "end")?;
Some(FpPrimitive::Rect {
start: Point::new(sx, sy),
end: Point::new(ex, ey),
width_mm: stroke_width(node).unwrap_or(0.1),
fill: true,
layer: String::new(),
})
}
fn parse_pad_poly(node: &Node) -> Option<FpPrimitive> {
let pts = find_list(node, "pts")?;
let mut points = Vec::new();
for xy in find_lists(pts, "xy") {
if let (Some(x), Some(y)) = (arg_f64(xy, 0), arg_f64(xy, 1)) {
points.push(Point::new(x, y));
}
}
if points.is_empty() { return None; }
Some(FpPrimitive::Poly {
points,
width_mm: stroke_width(node).unwrap_or(0.1),
fill: true,
layer: String::new(),
})
}
fn parse_drill(node: &Node) -> Option<Drill> {
let d = find_list(node, "drill")?;
let items = children(d);
let mut diameter = 0.0;
let mut oval = None;
let read_two = |i: usize| items.get(i + 1).and_then(as_f64);
let oval_flag = arg_str(d, 0) == Some("oval");
if oval_flag {
let a = read_two(1).unwrap_or(0.0);
let b = read_two(2).unwrap_or(a);
diameter = a;
oval = Some(b);
} else if let Some(v) = read_two(0) {
diameter = v;
}
let mut offset = None;
if let Some(off) = find_list(d, "offset") {
offset = Some((arg_f64(off, 0).unwrap_or(0.0), arg_f64(off, 1).unwrap_or(0.0)));
}
Some(Drill { diameter, oval, offset })
}
fn stroke_width(node: &Node) -> Option<f64> {
if let Some(stroke) = find_list(node, "stroke") {
if let Some(w) = find_list(stroke, "width") {
return arg_f64(w, 0);
}
}
find_list(node, "width").and_then(|n| arg_f64(n, 0))
}
fn layer_str(node: &Node) -> Option<String> {
find_list(node, "layer").and_then(|n| arg_str(n, 0)).map(str::to_string)
}
fn uuid_str(node: &Node) -> Option<String> {
find_list(node, "uuid").and_then(|n| arg_str(n, 0)).map(str::to_string)
}
fn has_fill_solid(node: &Node) -> bool {
if let Some(fill) = find_list(node, "fill") {
if let Some(v) = arg_str(fill, 0) {
return matches!(v, "solid" | "yes");
}
}
false
}

12
src/parse/mod.rs Normal file
View File

@ -0,0 +1,12 @@
pub mod board;
pub mod dimension;
pub mod footprint;
pub mod sexpr;
pub mod text;
pub mod zone;
pub use board::{Board, Item, Segment, ArcItem, GrLine, GrArc, GrRect, GrCircle, GrPoly, Via};
pub use dimension::{Dimension, DimensionKind};
pub use footprint::{AnchorShape, Drill, Footprint, FpPrimitive, Pad, PadKind, PadShape};
pub use text::{FpText, FpTextKind, GrText, JustifyH, JustifyV, TextEffects};
pub use zone::{FilledPolygon, Zone};

55
src/parse/sexpr.rs Normal file
View File

@ -0,0 +1,55 @@
use kiutils_sexpr::{Atom, Node};
pub fn head(node: &Node) -> Option<&str> {
let Node::List { items, .. } = node else { return None; };
items.first().and_then(symbol)
}
pub fn symbol(node: &Node) -> Option<&str> {
match node {
Node::Atom { atom: Atom::Symbol(s), .. } => Some(s.as_str()),
Node::Atom { atom: Atom::Quoted(s), .. } => Some(s.as_str()),
_ => None,
}
}
pub fn children<'a>(node: &'a Node) -> &'a [Node] {
match node {
Node::List { items, .. } => items,
_ => &[],
}
}
pub fn find_list<'a>(parent: &'a Node, head_name: &str) -> Option<&'a Node> {
children(parent)
.iter()
.find(|n| head(n) == Some(head_name))
}
pub fn find_lists<'a>(parent: &'a Node, head_name: &str) -> Vec<&'a Node> {
children(parent)
.iter()
.filter(|n| head(n) == Some(head_name))
.collect()
}
pub fn arg<'a>(list_node: &'a Node, index: usize) -> Option<&'a Node> {
children(list_node).get(index + 1)
}
pub fn arg_f64(list_node: &Node, index: usize) -> Option<f64> {
arg(list_node, index).and_then(as_f64)
}
pub fn arg_str<'a>(list_node: &'a Node, index: usize) -> Option<&'a str> {
arg(list_node, index).and_then(symbol)
}
pub fn as_f64(node: &Node) -> Option<f64> {
symbol(node).and_then(|s| s.parse::<f64>().ok())
}
pub fn xy_from<'a>(list_node: &'a Node, head_name: &str) -> Option<(f64, f64)> {
let l = find_list(list_node, head_name)?;
Some((arg_f64(l, 0)?, arg_f64(l, 1)?))
}

215
src/parse/text.rs Normal file
View File

@ -0,0 +1,215 @@
use kiutils_sexpr::Node;
use crate::geom::Point;
use crate::parse::sexpr::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FpTextKind {
Reference,
Value,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JustifyH {
#[default]
Centre,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JustifyV {
#[default]
Centre,
Top,
Bottom,
}
#[derive(Debug, Clone)]
pub struct TextEffects {
pub size_mm: (f64, f64),
pub thickness_mm: f64,
pub bold: bool,
pub italic: bool,
pub mirror: bool,
pub justify_h: JustifyH,
pub justify_v: JustifyV,
pub hide: bool,
}
impl Default for TextEffects {
fn default() -> Self {
Self {
size_mm: (1.0, 1.0),
thickness_mm: 0.15,
bold: false,
italic: false,
mirror: false,
justify_h: JustifyH::Centre,
justify_v: JustifyV::Centre,
hide: false,
}
}
}
#[derive(Debug, Clone)]
pub struct GrText {
pub text: String,
pub at: Point,
pub rotation_deg: f64,
pub layer: String,
pub effects: TextEffects,
pub uuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FpText {
pub kind: FpTextKind,
pub text: String,
pub at: Point,
pub rotation_deg: f64,
pub unlocked: bool,
pub layer: String,
pub effects: TextEffects,
pub uuid: Option<String>,
}
pub fn parse_gr_text(node: &Node) -> Option<GrText> {
let text = arg_str(node, 0)?.to_string();
let (ax, ay, rot) = read_at(node);
let layer = layer_str(node).unwrap_or_default();
let effects = read_effects(node);
let uuid = uuid_str(node);
Some(GrText {
text,
at: Point::new(ax, ay),
rotation_deg: rot,
layer,
effects,
uuid,
})
}
pub fn parse_property(node: &Node) -> Option<FpText> {
if head(node) != Some("property") { return None; }
let name = arg_str(node, 0)?;
let value = arg_str(node, 1)?.to_string();
if value.is_empty() { return None; }
let kind = match name {
"Reference" => FpTextKind::Reference,
"Value" => FpTextKind::Value,
_ => FpTextKind::User,
};
let (ax, ay, rot) = read_at(node);
let layer = layer_str(node).unwrap_or_default();
let mut effects = read_effects(node);
if children(node).iter().any(|n| head(n) == Some("hide")) {
if let Some(h) = find_list(node, "hide") {
if arg_str(h, 0) == Some("yes") {
effects.hide = true;
}
}
}
let uuid = uuid_str(node);
let unlocked = children(node).iter().any(|n| {
matches!(n, Node::Atom { atom: kiutils_sexpr::Atom::Symbol(s), .. } if s == "unlocked")
});
Some(FpText {
kind,
text: value,
at: Point::new(ax, ay),
rotation_deg: rot,
unlocked,
layer,
effects,
uuid,
})
}
pub fn parse_fp_text(node: &Node) -> Option<FpText> {
let kind_tok = arg_str(node, 0)?;
let kind = match kind_tok {
"reference" => FpTextKind::Reference,
"value" => FpTextKind::Value,
"user" => FpTextKind::User,
_ => return None,
};
let text = arg_str(node, 1)?.to_string();
let (ax, ay, rot) = read_at(node);
let layer = layer_str(node).unwrap_or_default();
let effects = read_effects(node);
let unlocked = children(node).iter().skip(1).any(|n| {
matches!(n, Node::Atom { atom: kiutils_sexpr::Atom::Symbol(s), .. } if s == "unlocked")
});
let uuid = uuid_str(node);
Some(FpText {
kind,
text,
at: Point::new(ax, ay),
rotation_deg: rot,
unlocked,
layer,
effects,
uuid,
})
}
fn read_at(node: &Node) -> (f64, f64, f64) {
let Some(at) = find_list(node, "at") else { return (0.0, 0.0, 0.0); };
let x = arg_f64(at, 0).unwrap_or(0.0);
let y = arg_f64(at, 1).unwrap_or(0.0);
let r = arg_f64(at, 2).unwrap_or(0.0);
(x, y, r)
}
fn read_effects(node: &Node) -> TextEffects {
let mut effects = TextEffects::default();
let Some(eff) = find_list(node, "effects") else {
return effects;
};
if let Some(font) = find_list(eff, "font") {
if let Some(s) = find_list(font, "size") {
let w = arg_f64(s, 0).unwrap_or(1.0);
let h = arg_f64(s, 1).unwrap_or(w);
effects.size_mm = (w, h);
}
if let Some(t) = find_list(font, "thickness") {
effects.thickness_mm = arg_f64(t, 0).unwrap_or(0.15);
}
if flag_present(font, "bold") { effects.bold = true; }
if flag_present(font, "italic") { effects.italic = true; }
}
if let Some(j) = find_list(eff, "justify") {
for child in children(j).iter().skip(1) {
match symbol(child) {
Some("left") => effects.justify_h = JustifyH::Left,
Some("right") => effects.justify_h = JustifyH::Right,
Some("top") => effects.justify_v = JustifyV::Top,
Some("bottom") => effects.justify_v = JustifyV::Bottom,
Some("mirror") => effects.mirror = true,
_ => {}
}
}
}
if flag_present(eff, "hide") { effects.hide = true; }
if let Some(h) = find_list(eff, "hide") {
if arg_str(h, 0) == Some("yes") { effects.hide = true; }
}
effects
}
fn flag_present(parent: &Node, name: &str) -> bool {
children(parent).iter().any(|n| {
matches!(n, Node::Atom { atom: kiutils_sexpr::Atom::Symbol(s), .. } if s == name)
})
}
fn layer_str(node: &Node) -> Option<String> {
find_list(node, "layer").and_then(|n| arg_str(n, 0)).map(str::to_string)
}
fn uuid_str(node: &Node) -> Option<String> {
find_list(node, "uuid").and_then(|n| arg_str(n, 0)).map(str::to_string)
}

89
src/parse/zone.rs Normal file
View File

@ -0,0 +1,89 @@
use kiutils_sexpr::Node;
use crate::geom::Point;
use crate::parse::sexpr::*;
#[derive(Debug, Clone)]
pub struct Zone {
pub layer: String,
pub uuid: Option<String>,
pub net_name: Option<String>,
pub outline: Vec<Point>,
pub filled_polygons: Vec<FilledPolygon>,
pub keepout: bool,
}
#[derive(Debug, Clone)]
pub struct FilledPolygon {
pub layer: String,
pub points: Vec<Point>,
}
pub fn parse_zone(node: &Node) -> Option<Zone> {
if head(node) != Some("zone") { return None; }
let primary_layer = find_list(node, "layer")
.and_then(|n| arg_str(n, 0))
.map(str::to_string);
let layers_list: Vec<String> = find_list(node, "layers")
.map(|l| children(l).iter().skip(1).filter_map(symbol).map(str::to_string).collect())
.unwrap_or_default();
let layer = primary_layer
.or_else(|| layers_list.first().cloned())
.unwrap_or_default();
let uuid = find_list(node, "uuid").and_then(|n| arg_str(n, 0)).map(str::to_string);
let net_name = find_list(node, "net_name")
.and_then(|n| arg_str(n, 0))
.map(str::to_string);
let keepout = find_list(node, "keepout").is_some();
let outline = find_list(node, "polygon")
.and_then(|p| find_list(p, "pts"))
.map(read_xy_list)
.unwrap_or_default();
let mut filled_polygons = Vec::new();
for fp in find_lists(node, "filled_polygon") {
let fp_layer = find_list(fp, "layer")
.and_then(|n| arg_str(n, 0))
.map(str::to_string)
.unwrap_or_else(|| layer.clone());
if let Some(pts) = find_list(fp, "pts") {
let points = read_xy_list(pts);
if points.len() >= 3 {
filled_polygons.push(FilledPolygon { layer: fp_layer, points });
}
}
}
Some(Zone { layer, uuid, net_name, outline, filled_polygons, keepout })
}
fn read_xy_list(pts: &Node) -> Vec<Point> {
let mut out = Vec::new();
for child in children(pts).iter().skip(1) {
match head(child) {
Some("xy") => {
if let (Some(x), Some(y)) = (arg_f64(child, 0), arg_f64(child, 1)) {
out.push(Point::new(x, y));
}
}
Some("arc") => {
if let (Some((sx, sy)), Some((mx, my)), Some((ex, ey))) = (
xy_from(child, "start"),
xy_from(child, "mid"),
xy_from(child, "end"),
) {
if out.is_empty() || out.last() != Some(&Point::new(sx, sy)) {
out.push(Point::new(sx, sy));
}
out.push(Point::new(mx, my));
out.push(Point::new(ex, ey));
}
}
_ => {}
}
}
out
}

103
src/render/dimension.rs Normal file
View File

@ -0,0 +1,103 @@
use crate::geom::{BBox, Point};
use crate::layer::{Rgba, Style};
use crate::parse::{Dimension, DimensionKind};
use crate::sink::Sink;
pub fn draw(dim: &Dimension, colour: Rgba, sink: &mut dyn Sink, bounds: &mut BBox) {
match dim.kind {
DimensionKind::Aligned | DimensionKind::Orthogonal => draw_linear(dim, colour, sink, bounds),
DimensionKind::Leader => draw_leader(dim, colour, sink, bounds),
DimensionKind::Center => draw_center(dim, colour, sink, bounds),
DimensionKind::Radial => draw_leader(dim, colour, sink, bounds),
DimensionKind::Unknown => {}
}
if let Some(t) = &dim.text {
if !t.effects.hide {
sink.text(&t.text, t.at, t.rotation_deg, &t.effects, colour);
}
}
}
fn draw_linear(dim: &Dimension, colour: Rgba, sink: &mut dyn Sink, bounds: &mut BBox) {
if dim.points.len() < 2 { return; }
let p1 = dim.points[0];
let p2 = dim.points[1];
let style = Style::stroke_only(colour, dim.thickness_mm.max(0.05));
let (dir_x, dir_y) = match dim.kind {
DimensionKind::Orthogonal => {
let dx = (p2.x - p1.x).abs();
let dy = (p2.y - p1.y).abs();
if dx >= dy { (1.0, 0.0) } else { (0.0, 1.0) }
}
_ => {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 { (1.0, 0.0) } else { (dx / len, dy / len) }
}
};
let nx = -dir_y;
let ny = dir_x;
let d1 = Point::new(p1.x + nx * dim.height, p1.y + ny * dim.height);
let d2 = Point::new(p2.x + nx * dim.height, p2.y + ny * dim.height);
let offset = dim.extension_offset;
let ext = dim.extension_height;
let e1a = Point::new(p1.x + nx * offset, p1.y + ny * offset);
let e1b = Point::new(p1.x + nx * (dim.height + ext), p1.y + ny * (dim.height + ext));
let e2a = Point::new(p2.x + nx * offset, p2.y + ny * offset);
let e2b = Point::new(p2.x + nx * (dim.height + ext), p2.y + ny * (dim.height + ext));
sink.line(e1a, e1b, &style);
sink.line(e2a, e2b, &style);
sink.line(d1, d2, &style);
draw_arrowhead(d1, (dir_x, dir_y), dim.arrow_length_mm, colour, sink);
draw_arrowhead(d2, (-dir_x, -dir_y), dim.arrow_length_mm, colour, sink);
for p in [p1, p2, d1, d2, e1a, e1b, e2a, e2b] {
bounds.expand_point(p);
}
}
fn draw_leader(dim: &Dimension, colour: Rgba, sink: &mut dyn Sink, bounds: &mut BBox) {
let style = Style::stroke_only(colour, dim.thickness_mm.max(0.05));
for pair in dim.points.windows(2) {
sink.line(pair[0], pair[1], &style);
bounds.expand_point(pair[0]);
bounds.expand_point(pair[1]);
}
if dim.points.len() >= 2 {
let p0 = dim.points[0];
let p1 = dim.points[1];
let dx = p1.x - p0.x;
let dy = p1.y - p0.y;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
draw_arrowhead(p0, (-dx / len, -dy / len), dim.arrow_length_mm, colour, sink);
}
}
fn draw_center(dim: &Dimension, colour: Rgba, sink: &mut dyn Sink, bounds: &mut BBox) {
let style = Style::stroke_only(colour, dim.thickness_mm.max(0.05));
let Some(c) = dim.points.first().copied() else { return; };
let arm = dim.arrow_length_mm.max(1.0);
sink.line(Point::new(c.x - arm, c.y), Point::new(c.x + arm, c.y), &style);
sink.line(Point::new(c.x, c.y - arm), Point::new(c.x, c.y + arm), &style);
bounds.expand_point(Point::new(c.x - arm, c.y - arm));
bounds.expand_point(Point::new(c.x + arm, c.y + arm));
}
fn draw_arrowhead(tip: Point, dir: (f64, f64), length: f64, colour: Rgba, sink: &mut dyn Sink) {
let (dx, dy) = dir;
let (nx, ny) = (-dy, dx);
let half_w = length * 0.25;
let base_x = tip.x + dx * length;
let base_y = tip.y + dy * length;
let p_a = Point::new(base_x + nx * half_w, base_y + ny * half_w);
let p_b = Point::new(base_x - nx * half_w, base_y - ny * half_w);
sink.polygon(&[tip, p_a, p_b], &Style::fill_only(colour));
}

212
src/render/footprint.rs Normal file
View File

@ -0,0 +1,212 @@
use crate::geom::{BBox, Point, Transform};
use crate::layer::{Rgba, Style};
use crate::parse::{AnchorShape, FpPrimitive, Pad, PadShape};
use crate::render::prim;
use crate::sink::Sink;
pub enum LayerCall<'a> {
Primitive { prim: &'a FpPrimitive, tf: Transform, palette_colour: Rgba },
Pad { pad: &'a Pad, fp_tf: Transform, palette_colour: Rgba },
}
pub fn draw(call: &LayerCall<'_>, sink: &mut dyn Sink, bounds: &mut BBox) {
match call {
LayerCall::Primitive { prim, tf, palette_colour } => draw_primitive(prim, *palette_colour, tf, sink, bounds),
LayerCall::Pad { pad, fp_tf, palette_colour } => draw_pad(pad, *palette_colour, fp_tf, sink, bounds),
}
}
fn draw_primitive(p: &FpPrimitive, colour: Rgba, tf: &Transform, sink: &mut dyn Sink, bounds: &mut BBox) {
match p {
FpPrimitive::Line { start, end, width_mm, .. } => {
prim::stroke_segment(*start, *end, *width_mm, colour, tf, sink, bounds);
}
FpPrimitive::Arc { start, mid, end, width_mm, .. } => {
prim::stroke_arc(*start, *mid, *end, *width_mm, colour, tf, sink, bounds);
}
FpPrimitive::Circle { centre, end, width_mm, fill, .. } => {
prim::circle(*centre, *end, *width_mm, *fill, colour, tf, sink, bounds);
}
FpPrimitive::Rect { start, end, width_mm, fill, .. } => {
prim::rect(*start, *end, *width_mm, *fill, colour, tf, sink, bounds);
}
FpPrimitive::Poly { points, width_mm, fill, .. } => {
prim::poly(points, *width_mm, *fill, colour, tf, sink, bounds);
}
}
}
fn draw_pad(pad: &Pad, colour: Rgba, fp_tf: &Transform, sink: &mut dyn Sink, bounds: &mut BBox) {
let pad_local = Transform::at(pad.at, pad.rotation_deg);
let pad_tf = compose(fp_tf, &pad_local);
let (w, h) = pad.size;
let hw = w * 0.5;
let hh = h * 0.5;
let style = Style::fill_only(colour);
match &pad.shape {
PadShape::Circle => {
let c = pad_tf.apply(Point::new(0.0, 0.0));
let r = hw.max(hh);
sink.circle(c, r, &style);
bounds.expand_point(Point::new(c.x - r, c.y - r));
bounds.expand_point(Point::new(c.x + r, c.y + r));
}
PadShape::Rect => {
let pts = [
pad_tf.apply(Point::new(-hw, -hh)),
pad_tf.apply(Point::new( hw, -hh)),
pad_tf.apply(Point::new( hw, hh)),
pad_tf.apply(Point::new(-hw, hh)),
];
sink.polygon(&pts, &style);
for p in &pts { bounds.expand_point(*p); }
}
PadShape::Roundrect { radius_ratio } => {
let r = hw.min(hh) * radius_ratio;
draw_rounded_rect(pad_tf, hw, hh, r, &style, sink, bounds);
}
PadShape::Oval => {
draw_oval(pad_tf, hw, hh, &style, sink, bounds);
}
PadShape::Trapezoid { delta } => {
let (dx, dy) = *delta;
let pts = [
pad_tf.apply(Point::new(-hw - dx * 0.5, -hh - dy * 0.5)),
pad_tf.apply(Point::new( hw + dx * 0.5, -hh + dy * 0.5)),
pad_tf.apply(Point::new( hw - dx * 0.5, hh + dy * 0.5)),
pad_tf.apply(Point::new(-hw + dx * 0.5, hh - dy * 0.5)),
];
sink.polygon(&pts, &style);
for p in &pts { bounds.expand_point(*p); }
}
PadShape::Custom => {
match pad.anchor_shape {
AnchorShape::Circle => {
let c = pad_tf.apply(Point::new(0.0, 0.0));
let r = hw.max(hh);
if r > 1e-9 {
sink.circle(c, r, &style);
bounds.expand_point(Point::new(c.x - r, c.y - r));
bounds.expand_point(Point::new(c.x + r, c.y + r));
}
}
AnchorShape::Rect => {
if hw > 1e-9 && hh > 1e-9 {
let pts = [
pad_tf.apply(Point::new(-hw, -hh)),
pad_tf.apply(Point::new( hw, -hh)),
pad_tf.apply(Point::new( hw, hh)),
pad_tf.apply(Point::new(-hw, hh)),
];
sink.polygon(&pts, &style);
for p in &pts { bounds.expand_point(*p); }
}
}
}
for prim in &pad.primitives {
draw_custom_primitive(prim, colour, &pad_tf, sink, bounds);
}
}
}
if let Some(drill) = &pad.drill {
let c = pad_tf.apply(Point::new(
drill.offset.map(|o| o.0).unwrap_or(0.0),
drill.offset.map(|o| o.1).unwrap_or(0.0),
));
let r = drill.diameter * 0.5;
sink.circle(c, r, &Style::fill_only(Rgba::opaque(15, 15, 15)));
}
}
fn compose(outer: &Transform, inner: &Transform) -> Transform {
let local = outer.apply(inner.translate);
Transform {
translate: local,
rotation_rad: outer.rotation_rad + inner.rotation_rad,
mirror_x: outer.mirror_x ^ inner.mirror_x,
}
}
fn draw_custom_primitive(
prim: &FpPrimitive,
colour: Rgba,
tf: &Transform,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
match prim {
FpPrimitive::Line { start, end, width_mm, .. } => {
prim::stroke_segment(*start, *end, *width_mm, colour, tf, sink, bounds);
}
FpPrimitive::Arc { start, mid, end, width_mm, .. } => {
prim::stroke_arc(*start, *mid, *end, *width_mm, colour, tf, sink, bounds);
}
FpPrimitive::Circle { centre, end, width_mm, fill, .. } => {
prim::circle(*centre, *end, *width_mm, *fill, colour, tf, sink, bounds);
}
FpPrimitive::Rect { start, end, width_mm, fill, .. } => {
prim::rect(*start, *end, *width_mm, *fill, colour, tf, sink, bounds);
}
FpPrimitive::Poly { points, width_mm, fill, .. } => {
prim::poly(points, *width_mm, *fill, colour, tf, sink, bounds);
}
}
}
fn draw_rounded_rect(
tf: Transform,
hw: f64,
hh: f64,
r: f64,
style: &Style,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
let r = r.min(hw).min(hh);
let segs_per_corner = 6;
let mut pts = Vec::with_capacity(segs_per_corner * 4 + 4);
let corners = [
(Point::new( hw - r, hh - r), 0.0),
(Point::new(-hw + r, hh - r), std::f64::consts::FRAC_PI_2),
(Point::new(-hw + r, -hh + r), std::f64::consts::PI),
(Point::new( hw - r, -hh + r), 3.0 * std::f64::consts::FRAC_PI_2),
];
for (centre, start_angle) in corners {
for i in 0..=segs_per_corner {
let t = i as f64 / segs_per_corner as f64;
let a = start_angle + t * std::f64::consts::FRAC_PI_2;
pts.push(Point::new(centre.x + r * a.cos(), centre.y + r * a.sin()));
}
}
let world: Vec<Point> = pts.iter().map(|p| tf.apply(*p)).collect();
sink.polygon(&world, style);
for p in &world { bounds.expand_point(*p); }
}
fn draw_oval(
tf: Transform,
hw: f64,
hh: f64,
style: &Style,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
if (hw - hh).abs() < 1e-9 {
let c = tf.apply(Point::new(0.0, 0.0));
sink.circle(c, hw, style);
bounds.expand_point(Point::new(c.x - hw, c.y - hw));
bounds.expand_point(Point::new(c.x + hw, c.y + hw));
return;
}
let segs = 32;
let mut pts = Vec::with_capacity(segs);
for i in 0..segs {
let a = (i as f64) / (segs as f64) * std::f64::consts::TAU;
pts.push(Point::new(hw * a.cos(), hh * a.sin()));
}
let world: Vec<Point> = pts.iter().map(|p| tf.apply(*p)).collect();
sink.polygon(&world, style);
for p in &world { bounds.expand_point(*p); }
}

288
src/render/mod.rs Normal file
View File

@ -0,0 +1,288 @@
pub mod dimension;
pub mod footprint;
pub mod palette;
pub mod prim;
use std::collections::BTreeMap;
use crate::geom::{BBox, Point, Transform};
use crate::layer::{LayerName, Rgba, Style};
use crate::parse::{Board, Dimension, FilledPolygon, Footprint, FpText, GrText, Item};
use crate::sink::Sink;
pub use palette::Palette;
use footprint::LayerCall;
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub palette: Palette,
pub layer_filter: Option<Vec<String>>,
pub uuid_filter: Option<std::collections::HashSet<String>>,
pub draw_vias: bool,
pub draw_pads: bool,
pub draw_text: bool,
pub include_reference_text: bool,
pub include_value_text: bool,
pub include_hidden_text: bool,
pub draw_zones: bool,
pub draw_dimensions: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
palette: Palette::default(),
layer_filter: None,
uuid_filter: None,
draw_vias: true,
draw_pads: true,
draw_text: true,
include_reference_text: true,
include_value_text: false,
include_hidden_text: false,
draw_zones: true,
draw_dimensions: true,
}
}
}
pub fn render(board: &Board, sink: &mut dyn Sink, opts: &RenderOptions) -> BBox {
let mut by_layer: BTreeMap<String, Vec<Draw<'_>>> = BTreeMap::new();
let mut vias: Vec<&crate::parse::Via> = Vec::new();
for item in &board.items {
if let Some(uuid) = item_uuid(item) {
if let Some(keep) = &opts.uuid_filter {
if !keep.contains(uuid) { continue; }
}
}
match item {
Item::Via(v) => if opts.draw_vias { vias.push(v); },
Item::Footprint(fp) => fold_footprint(fp, opts, &mut by_layer),
Item::GrText(t) => {
if !opts.draw_text { continue; }
if t.effects.hide && !opts.include_hidden_text { continue; }
if layer_allowed(&t.layer, opts) {
by_layer.entry(t.layer.clone()).or_default().push(Draw::GrText(t));
}
}
Item::Zone(z) => {
if !opts.draw_zones { continue; }
if z.keepout { continue; }
for fp_poly in &z.filled_polygons {
if layer_allowed(&fp_poly.layer, opts) {
by_layer.entry(fp_poly.layer.clone()).or_default().push(Draw::ZoneFill(fp_poly));
}
}
}
Item::Dimension(d) => {
if !opts.draw_dimensions { continue; }
if layer_allowed(&d.layer, opts) {
by_layer.entry(d.layer.clone()).or_default().push(Draw::Dimension(d));
}
}
_ => {
let layer = item_layer(item).to_string();
if layer_allowed(&layer, opts) {
by_layer.entry(layer).or_default().push(Draw::Item(item));
}
}
}
}
let mut bounds = BBox::empty();
for (layer, draws) in &by_layer {
let ln = LayerName::new(layer.clone());
sink.begin_layer(&ln);
let colour = opts.palette.colour(layer);
for draw in draws {
match draw {
Draw::Item(item) => draw_item(item, colour, sink, &mut bounds),
Draw::Fp(call) => footprint::draw(call, sink, &mut bounds),
Draw::GrText(t) => {
sink.text(&t.text, t.at, t.rotation_deg, &t.effects, colour);
expand_text_bounds(t.at, &t.effects, t.text.len(), &mut bounds);
}
Draw::FpText { text, tf } => {
let world = tf.apply(text.at);
let rot = tf.rotation_rad.to_degrees() + text.rotation_deg;
sink.text(&text.text, world, rot, &text.effects, colour);
expand_text_bounds(world, &text.effects, text.text.len(), &mut bounds);
}
Draw::ZoneFill(fp_poly) => {
let style = Style::fill_only(colour);
sink.polygon(&fp_poly.points, &style);
for p in &fp_poly.points {
bounds.expand_point(*p);
}
}
Draw::Dimension(d) => {
dimension::draw(d, colour, sink, &mut bounds);
}
}
}
sink.end_layer(&ln);
}
if opts.draw_vias && !vias.is_empty() {
let ln = LayerName::new("*.via".to_string());
sink.begin_layer(&ln);
for v in vias {
draw_via(v, &opts.palette, sink, &mut bounds);
}
sink.end_layer(&ln);
}
bounds
}
enum Draw<'a> {
Item(&'a Item),
Fp(LayerCall<'a>),
GrText(&'a GrText),
FpText { text: &'a FpText, tf: Transform },
ZoneFill(&'a FilledPolygon),
Dimension(&'a Dimension),
}
fn expand_text_bounds(pos: Point, effects: &crate::parse::TextEffects, len: usize, bounds: &mut BBox) {
let w = effects.size_mm.0 * len as f64 * 0.6;
let h = effects.size_mm.1;
bounds.expand_point(Point::new(pos.x - w, pos.y - h));
bounds.expand_point(Point::new(pos.x + w, pos.y + h));
}
fn fold_footprint<'a>(
fp: &'a Footprint,
opts: &RenderOptions,
out: &mut BTreeMap<String, Vec<Draw<'a>>>,
) {
let tf = Transform::at(fp.at, fp.rotation_deg);
for p in &fp.primitives {
let layer = match p {
crate::parse::FpPrimitive::Line { layer, .. }
| crate::parse::FpPrimitive::Arc { layer, .. }
| crate::parse::FpPrimitive::Circle { layer, .. }
| crate::parse::FpPrimitive::Rect { layer, .. }
| crate::parse::FpPrimitive::Poly { layer, .. } => layer.clone(),
};
if !layer_allowed(&layer, opts) { continue; }
let colour = opts.palette.colour(&layer);
out.entry(layer).or_default().push(Draw::Fp(LayerCall::Primitive {
prim: p, tf, palette_colour: colour,
}));
}
if opts.draw_pads {
for pad in &fp.pads {
for layer in &pad.layers {
for expanded in expand_wildcard_layer(layer) {
if !layer_allowed(&expanded, opts) { continue; }
let colour = opts.palette.colour(&expanded);
out.entry(expanded).or_default().push(Draw::Fp(LayerCall::Pad {
pad, fp_tf: tf, palette_colour: colour,
}));
}
}
}
}
if opts.draw_text {
for text in &fp.texts {
use crate::parse::FpTextKind;
let skip = match text.kind {
FpTextKind::Reference => !opts.include_reference_text,
FpTextKind::Value => !opts.include_value_text,
FpTextKind::User => false,
};
if skip { continue; }
if text.effects.hide && !opts.include_hidden_text { continue; }
if !layer_allowed(&text.layer, opts) { continue; }
out.entry(text.layer.clone()).or_default().push(Draw::FpText { text, tf });
}
}
}
fn layer_allowed(layer: &str, opts: &RenderOptions) -> bool {
match &opts.layer_filter {
Some(filter) => filter.iter().any(|l| l == layer),
None => true,
}
}
fn expand_wildcard_layer(layer: &str) -> Vec<String> {
let wildcards: &[&str] = match layer {
"*.Cu" => &["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "B.Cu"],
"*.Mask" => &["F.Mask", "B.Mask"],
"*.Paste" => &["F.Paste", "B.Paste"],
"*.SilkS" | "*.Silkscreen" => &["F.SilkS", "B.SilkS"],
"*.Fab" => &["F.Fab", "B.Fab"],
"*.CrtYd" | "*.Courtyard" => &["F.CrtYd", "B.CrtYd"],
"F&B.Cu" => &["F.Cu", "B.Cu"],
_ => return vec![layer.to_string()],
};
wildcards.iter().map(|s| s.to_string()).collect()
}
fn item_layer(item: &Item) -> &str {
match item {
Item::Segment(s) => &s.layer,
Item::Arc(a) => &a.layer,
Item::GrLine(g) => &g.layer,
Item::GrArc(g) => &g.layer,
Item::GrRect(g) => &g.layer,
Item::GrCircle(g) => &g.layer,
Item::GrPoly(g) => &g.layer,
Item::GrText(t) => &t.layer,
Item::Zone(z) => &z.layer,
Item::Dimension(d) => &d.layer,
Item::Via(_) => "*.via",
Item::Footprint(f) => &f.reference_layer,
}
}
fn item_uuid(item: &Item) -> Option<&str> {
match item {
Item::Segment(s) => s.uuid.as_deref(),
Item::Arc(a) => a.uuid.as_deref(),
Item::Via(v) => v.uuid.as_deref(),
Item::GrLine(g) => g.uuid.as_deref(),
Item::GrArc(g) => g.uuid.as_deref(),
Item::GrRect(g) => g.uuid.as_deref(),
Item::GrCircle(g) => g.uuid.as_deref(),
Item::GrPoly(g) => g.uuid.as_deref(),
Item::GrText(t) => t.uuid.as_deref(),
Item::Zone(z) => z.uuid.as_deref(),
Item::Dimension(d) => d.uuid.as_deref(),
Item::Footprint(f) => f.uuid.as_deref(),
}
}
fn draw_item(item: &Item, colour: Rgba, sink: &mut dyn Sink, bounds: &mut BBox) {
let tf = Transform::IDENTITY;
match item {
Item::Segment(s) => prim::stroke_segment(s.start, s.end, s.width_mm, colour, &tf, sink, bounds),
Item::Arc(a) => prim::stroke_arc(a.start, a.mid, a.end, a.width_mm, colour, &tf, sink, bounds),
Item::GrLine(g) => prim::stroke_segment(g.start, g.end, g.width_mm, colour, &tf, sink, bounds),
Item::GrArc(g) => prim::stroke_arc(g.start, g.mid, g.end, g.width_mm, colour, &tf, sink, bounds),
Item::GrRect(g) => prim::rect(g.start, g.end, g.width_mm, g.fill, colour, &tf, sink, bounds),
Item::GrCircle(g) => prim::circle(g.centre, g.end, g.width_mm, g.fill, colour, &tf, sink, bounds),
Item::GrPoly(g) => prim::poly(&g.points, g.width_mm, g.fill, colour, &tf, sink, bounds),
Item::Via(_)
| Item::Footprint(_)
| Item::GrText(_)
| Item::Zone(_)
| Item::Dimension(_) => {}
}
}
fn draw_via(v: &crate::parse::Via, palette: &Palette, sink: &mut dyn Sink, bounds: &mut BBox) {
let outer = palette.via_body();
let drill = palette.via_drill();
let ro = v.size_mm * 0.5;
let rd = v.drill_mm * 0.5;
sink.circle(v.at, ro, &Style::fill_only(outer));
sink.circle(v.at, rd, &Style::fill_only(drill));
bounds.expand_point(crate::geom::Point::new(v.at.x - ro, v.at.y - ro));
bounds.expand_point(crate::geom::Point::new(v.at.x + ro, v.at.y + ro));
}

61
src/render/palette.rs Normal file
View File

@ -0,0 +1,61 @@
use crate::layer::Rgba;
#[derive(Debug, Clone)]
pub struct Palette {
pub default: Rgba,
pub overrides: Vec<(String, Rgba)>,
pub via_body: Rgba,
pub via_drill: Rgba,
}
impl Palette {
pub fn colour(&self, layer: &str) -> Rgba {
self.overrides
.iter()
.find(|(n, _)| n == layer)
.map(|(_, c)| *c)
.unwrap_or(self.default)
}
pub fn with_override(mut self, layer: impl Into<String>, colour: Rgba) -> Self {
self.overrides.push((layer.into(), colour));
self
}
pub fn via_body(&self) -> Rgba { self.via_body }
pub fn via_drill(&self) -> Rgba { self.via_drill }
}
impl Default for Palette {
fn default() -> Self {
Self {
default: Rgba::opaque(180, 180, 180),
overrides: vec![
("F.Cu".into(), Rgba::opaque(200, 52, 52)),
("B.Cu".into(), Rgba::opaque(77, 127, 196)),
("In1.Cu".into(), Rgba::opaque(127, 200, 127)),
("In2.Cu".into(), Rgba::opaque(206, 125, 188)),
("F.Silkscreen".into(), Rgba::opaque(220, 200, 200)),
("B.Silkscreen".into(), Rgba::opaque(200, 220, 220)),
("F.SilkS".into(), Rgba::opaque(220, 200, 200)),
("B.SilkS".into(), Rgba::opaque(200, 220, 220)),
("F.Mask".into(), Rgba::opaque(102, 0, 102)),
("B.Mask".into(), Rgba::opaque(102, 0, 102)),
("F.Paste".into(), Rgba::opaque(180, 180, 180)),
("B.Paste".into(), Rgba::opaque(180, 180, 180)),
("F.Fab".into(), Rgba::opaque(150, 115, 60)),
("B.Fab".into(), Rgba::opaque(150, 115, 60)),
("F.Courtyard".into(), Rgba::opaque(255, 38, 226)),
("B.Courtyard".into(), Rgba::opaque(38, 233, 255)),
("F.CrtYd".into(), Rgba::opaque(255, 38, 226)),
("B.CrtYd".into(), Rgba::opaque(38, 233, 255)),
("Edge.Cuts".into(), Rgba::opaque(255, 240, 150)),
("Margin".into(), Rgba::opaque(255, 120, 120)),
("Dwgs.User".into(), Rgba::opaque(194, 194, 194)),
("Cmts.User".into(), Rgba::opaque(89, 148, 220)),
],
via_body: Rgba::opaque(200, 170, 80),
via_drill: Rgba::opaque(30, 30, 30),
}
}
}

113
src/render/prim.rs Normal file
View File

@ -0,0 +1,113 @@
use crate::geom::{Arc, BBox, Point, Transform};
use crate::layer::{Rgba, Style};
use crate::sink::Sink;
pub fn stroke_segment(
start: Point,
end: Point,
width_mm: f64,
colour: Rgba,
tf: &Transform,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
let a = tf.apply(start);
let b = tf.apply(end);
sink.line(a, b, &Style::stroke_only(colour, width_mm));
bounds.expand_point(a);
bounds.expand_point(b);
}
pub fn stroke_arc(
start: Point,
mid: Point,
end: Point,
width_mm: f64,
colour: Rgba,
tf: &Transform,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
let a = tf.apply(start);
let m = tf.apply(mid);
let e = tf.apply(end);
sink.arc(Arc::new(a, m, e), &Style::stroke_only(colour, width_mm));
bounds.expand_point(a);
bounds.expand_point(m);
bounds.expand_point(e);
}
pub fn circle(
centre: Point,
end: Point,
width_mm: f64,
fill: bool,
colour: Rgba,
tf: &Transform,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
let c = tf.apply(centre);
let e = tf.apply(end);
let r = c.distance_to(e);
let style = if fill {
Style::fill_and_stroke(colour, colour, width_mm)
} else {
Style::stroke_only(colour, width_mm)
};
sink.circle(c, r, &style);
bounds.expand_point(Point::new(c.x - r, c.y - r));
bounds.expand_point(Point::new(c.x + r, c.y + r));
}
pub fn rect(
a: Point,
b: Point,
width_mm: f64,
fill: bool,
colour: Rgba,
tf: &Transform,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
let pts = [
tf.apply(Point::new(a.x, a.y)),
tf.apply(Point::new(b.x, a.y)),
tf.apply(Point::new(b.x, b.y)),
tf.apply(Point::new(a.x, b.y)),
];
let style = if fill {
Style::fill_and_stroke(colour, colour, width_mm)
} else {
Style::stroke_only(colour, width_mm)
};
if tf.rotation_rad.abs() < 1e-9 && !tf.mirror_x {
sink.rect(pts[0], pts[2], &style);
} else {
sink.polygon(&pts, &style);
}
for p in &pts {
bounds.expand_point(*p);
}
}
pub fn poly(
points: &[Point],
width_mm: f64,
fill: bool,
colour: Rgba,
tf: &Transform,
sink: &mut dyn Sink,
bounds: &mut BBox,
) {
let transformed: Vec<Point> = points.iter().map(|p| tf.apply(*p)).collect();
let style = if fill {
Style::fill_and_stroke(colour, colour, width_mm)
} else {
Style::stroke_only(colour, width_mm)
};
sink.polyline(&transformed, fill, &style);
for p in &transformed {
bounds.expand_point(*p);
}
}

30
src/sink/mod.rs Normal file
View File

@ -0,0 +1,30 @@
pub mod svg;
use crate::geom::{Arc, Point};
use crate::layer::{LayerName, Rgba, Style};
use crate::parse::TextEffects;
pub trait Sink {
fn begin_layer(&mut self, _layer: &LayerName) {}
fn end_layer(&mut self, _layer: &LayerName) {}
fn line(&mut self, a: Point, b: Point, style: &Style);
fn arc(&mut self, arc: Arc, style: &Style);
fn circle(&mut self, centre: Point, radius: f64, style: &Style);
fn polyline(&mut self, points: &[Point], closed: bool, style: &Style);
fn polygon(&mut self, points: &[Point], style: &Style);
fn rect(&mut self, a: Point, b: Point, style: &Style);
fn text(
&mut self,
text: &str,
pos: Point,
rotation_deg: f64,
effects: &TextEffects,
colour: Rgba,
) {
let _ = (text, pos, rotation_deg, effects, colour);
}
}
pub use svg::SvgSink;

219
src/sink/svg.rs Normal file
View File

@ -0,0 +1,219 @@
use std::fmt::Write as _;
use crate::geom::{Arc, BBox, Point};
use crate::layer::{LayerName, Rgba, Style};
use crate::parse::{JustifyH, JustifyV, TextEffects};
use crate::sink::Sink;
pub struct SvgSink {
pub bounds: BBox,
pub padding_mm: f64,
body: String,
active_layer: Option<String>,
layer_open: bool,
}
impl SvgSink {
pub fn new(bounds: BBox) -> Self {
Self {
bounds,
padding_mm: 0.5,
body: String::new(),
active_layer: None,
layer_open: false,
}
}
pub fn finish(mut self) -> String {
if self.layer_open {
self.body.push_str("</g>\n");
self.layer_open = false;
}
let mut bb = self.bounds;
bb.inflate(self.padding_mm);
let (w, h) = (bb.width().max(0.001), bb.height().max(0.001));
let mut out = String::with_capacity(self.body.len() + 256);
let _ = write!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{:.4} {:.4} {:.4} {:.4}\" width=\"{:.4}mm\" height=\"{:.4}mm\">\n",
bb.min_x, bb.min_y, w, h, w, h
);
out.push_str(&self.body);
out.push_str("</svg>\n");
out
}
fn write_attrs(&mut self, style: &Style) {
match style.stroke {
Some(c) => {
let _ = write!(self.body, " stroke=\"{}\"", rgb_hex(c));
if c.a != 255 {
let _ = write!(self.body, " stroke-opacity=\"{:.4}\"", c.a as f64 / 255.0);
}
let _ = write!(
self.body,
" stroke-width=\"{:.4}\" stroke-linecap=\"round\" stroke-linejoin=\"round\"",
style.stroke_width_mm.max(0.0)
);
}
None => self.body.push_str(" stroke=\"none\""),
}
match style.fill {
Some(c) => {
let _ = write!(self.body, " fill=\"{}\"", rgb_hex(c));
if c.a != 255 {
let _ = write!(self.body, " fill-opacity=\"{:.4}\"", c.a as f64 / 255.0);
}
}
None => self.body.push_str(" fill=\"none\""),
}
}
}
fn rgb_hex(c: Rgba) -> String {
format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
}
impl Sink for SvgSink {
fn begin_layer(&mut self, layer: &LayerName) {
if self.layer_open {
self.body.push_str("</g>\n");
}
let _ = write!(self.body, "<g data-layer=\"{}\">\n", escape_xml(layer.as_str()));
self.active_layer = Some(layer.0.clone());
self.layer_open = true;
}
fn end_layer(&mut self, _layer: &LayerName) {
if self.layer_open {
self.body.push_str("</g>\n");
self.layer_open = false;
self.active_layer = None;
}
}
fn line(&mut self, a: Point, b: Point, style: &Style) {
let _ = write!(
self.body,
"<line x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\"",
a.x, a.y, b.x, b.y
);
self.write_attrs(style);
self.body.push_str("/>\n");
}
fn arc(&mut self, arc: Arc, style: &Style) {
let Some((_, r)) = arc.centre_and_radius() else {
self.line(arc.start, arc.end, style);
return;
};
let sweep = if arc.sweep_sign() > 0.0 { 1 } else { 0 };
let _ = write!(
self.body,
"<path d=\"M {:.4} {:.4} A {:.4} {:.4} 0 0 {} {:.4} {:.4}\"",
arc.start.x, arc.start.y, r, r, sweep, arc.end.x, arc.end.y
);
self.write_attrs(style);
self.body.push_str("/>\n");
}
fn circle(&mut self, centre: Point, radius: f64, style: &Style) {
let _ = write!(
self.body,
"<circle cx=\"{:.4}\" cy=\"{:.4}\" r=\"{:.4}\"",
centre.x, centre.y, radius
);
self.write_attrs(style);
self.body.push_str("/>\n");
}
fn polyline(&mut self, points: &[Point], closed: bool, style: &Style) {
if points.is_empty() { return; }
let tag = if closed { "polygon" } else { "polyline" };
let _ = write!(self.body, "<{tag} points=\"");
for (i, p) in points.iter().enumerate() {
if i > 0 { self.body.push(' '); }
let _ = write!(self.body, "{:.4},{:.4}", p.x, p.y);
}
self.body.push('"');
self.write_attrs(style);
self.body.push_str("/>\n");
}
fn polygon(&mut self, points: &[Point], style: &Style) {
self.polyline(points, true, style);
}
fn rect(&mut self, a: Point, b: Point, style: &Style) {
let (x, w) = if a.x <= b.x { (a.x, b.x - a.x) } else { (b.x, a.x - b.x) };
let (y, h) = if a.y <= b.y { (a.y, b.y - a.y) } else { (b.y, a.y - b.y) };
let _ = write!(
self.body,
"<rect x=\"{:.4}\" y=\"{:.4}\" width=\"{:.4}\" height=\"{:.4}\"",
x, y, w, h
);
self.write_attrs(style);
self.body.push_str("/>\n");
}
fn text(
&mut self,
text: &str,
pos: Point,
rotation_deg: f64,
effects: &TextEffects,
colour: Rgba,
) {
if effects.hide || text.is_empty() { return; }
let anchor = match effects.justify_h {
JustifyH::Left => "start",
JustifyH::Centre => "middle",
JustifyH::Right => "end",
};
let baseline = match effects.justify_v {
JustifyV::Top => "hanging",
JustifyV::Centre => "middle",
JustifyV::Bottom => "alphabetic",
};
let fill_hex = rgb_hex(colour);
let alpha = colour.a as f64 / 255.0;
let font_size = effects.size_mm.1.max(0.01);
let stretch = if effects.size_mm.0 > 0.0 && effects.size_mm.1 > 0.0 {
effects.size_mm.0 / effects.size_mm.1
} else { 1.0 };
let escaped = escape_xml(text);
let weight = if effects.bold { "bold" } else { "normal" };
let style = if effects.italic { "italic" } else { "normal" };
let rot = rotation_deg;
let scale_x = if effects.mirror { -stretch } else { stretch };
let _ = write!(
self.body,
"<g transform=\"translate({:.4} {:.4}) rotate({:.4}) scale({:.4} 1)\">\
<text x=\"0\" y=\"0\" font-family=\"monospace\" font-size=\"{:.4}\" \
text-anchor=\"{}\" dominant-baseline=\"{}\" font-weight=\"{}\" \
font-style=\"{}\" fill=\"{}\"",
pos.x, pos.y, rot, scale_x, font_size, anchor, baseline, weight, style, fill_hex
);
if alpha < 1.0 {
let _ = write!(self.body, " fill-opacity=\"{:.4}\"", alpha);
}
let _ = write!(self.body, ">{}</text></g>\n", escaped);
}
}
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
'"' => out.push_str("&quot;"),
_ => out.push(ch),
}
}
out
}