From a8421dab3a933c2fff9ef33551cd283eb3f267d6 Mon Sep 17 00:00:00 2001 From: jess Date: Wed, 22 Apr 2026 10:13:08 -0700 Subject: [PATCH] Init --- .gitignore | 4 + Cargo.toml | 12 ++ LICENCE | 12 ++ examples/demo.rs | 48 +++++ examples/lib_lookup.rs | 22 +++ examples/render_board.rs | 81 ++++++++ src/geom/arc.rs | 41 ++++ src/geom/bbox.rs | 49 +++++ src/geom/mod.rs | 9 + src/geom/point.rs | 21 +++ src/geom/transform.rs | 40 ++++ src/layer.rs | 39 ++++ src/lib.rs | 20 ++ src/library/mod.rs | 5 + src/library/resolver.rs | 182 ++++++++++++++++++ src/library/table.rs | 101 ++++++++++ src/parse/board.rs | 316 +++++++++++++++++++++++++++++++ src/parse/dimension.rs | 76 ++++++++ src/parse/footprint.rs | 394 +++++++++++++++++++++++++++++++++++++++ src/parse/mod.rs | 12 ++ src/parse/sexpr.rs | 55 ++++++ src/parse/text.rs | 215 +++++++++++++++++++++ src/parse/zone.rs | 89 +++++++++ src/render/dimension.rs | 103 ++++++++++ src/render/footprint.rs | 212 +++++++++++++++++++++ src/render/mod.rs | 288 ++++++++++++++++++++++++++++ src/render/palette.rs | 61 ++++++ src/render/prim.rs | 113 +++++++++++ src/sink/mod.rs | 30 +++ src/sink/svg.rs | 219 ++++++++++++++++++++++ 30 files changed, 2869 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENCE create mode 100644 examples/demo.rs create mode 100644 examples/lib_lookup.rs create mode 100644 examples/render_board.rs create mode 100644 src/geom/arc.rs create mode 100644 src/geom/bbox.rs create mode 100644 src/geom/mod.rs create mode 100644 src/geom/point.rs create mode 100644 src/geom/transform.rs create mode 100644 src/layer.rs create mode 100644 src/lib.rs create mode 100644 src/library/mod.rs create mode 100644 src/library/resolver.rs create mode 100644 src/library/table.rs create mode 100644 src/parse/board.rs create mode 100644 src/parse/dimension.rs create mode 100644 src/parse/footprint.rs create mode 100644 src/parse/mod.rs create mode 100644 src/parse/sexpr.rs create mode 100644 src/parse/text.rs create mode 100644 src/parse/zone.rs create mode 100644 src/render/dimension.rs create mode 100644 src/render/footprint.rs create mode 100644 src/render/mod.rs create mode 100644 src/render/palette.rs create mode 100644 src/render/prim.rs create mode 100644 src/sink/mod.rs create mode 100644 src/sink/svg.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3998843 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +*.swp +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3dbec6c --- /dev/null +++ b/Cargo.toml @@ -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] diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..1cebcb5 --- /dev/null +++ b/LICENCE @@ -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. diff --git a/examples/demo.rs b/examples/demo.rs new file mode 100644 index 0000000..5b632b5 --- /dev/null +++ b/examples/demo.rs @@ -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()); +} diff --git a/examples/lib_lookup.rs b/examples/lib_lookup.rs new file mode 100644 index 0000000..1a38edb --- /dev/null +++ b/examples/lib_lookup.rs @@ -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}"); +} diff --git a/examples/render_board.rs b/examples/render_board.rs new file mode 100644 index 0000000..880e064 --- /dev/null +++ b/examples/render_board.rs @@ -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 [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 +} diff --git a/src/geom/arc.rs b/src/geom/arc.rs new file mode 100644 index 0000000..3fc4552 --- /dev/null +++ b/src/geom/arc.rs @@ -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 } + } +} diff --git a/src/geom/bbox.rs b/src/geom/bbox.rs new file mode 100644 index 0000000..9069aaf --- /dev/null +++ b/src/geom/bbox.rs @@ -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 } +} diff --git a/src/geom/mod.rs b/src/geom/mod.rs new file mode 100644 index 0000000..96108b7 --- /dev/null +++ b/src/geom/mod.rs @@ -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; diff --git a/src/geom/point.rs b/src/geom/point.rs new file mode 100644 index 0000000..5393273 --- /dev/null +++ b/src/geom/point.rs @@ -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) + } +} diff --git a/src/geom/transform.rs b/src/geom/transform.rs new file mode 100644 index 0000000..d35e2e7 --- /dev/null +++ b/src/geom/transform.rs @@ -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 } +} diff --git a/src/layer.rs b/src/layer.rs new file mode 100644 index 0000000..4fa5294 --- /dev/null +++ b/src/layer.rs @@ -0,0 +1,39 @@ +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LayerName(pub String); + +impl LayerName { + pub fn new>(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, + pub stroke_width_mm: f64, + pub fill: Option, +} + +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) } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f0ada6e --- /dev/null +++ b/src/lib.rs @@ -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 { + 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()) +} diff --git a/src/library/mod.rs b/src/library/mod.rs new file mode 100644 index 0000000..50093e1 --- /dev/null +++ b/src/library/mod.rs @@ -0,0 +1,5 @@ +pub mod table; +pub mod resolver; + +pub use resolver::{LibraryResolver, LibError}; +pub use table::{FpLibTable, LibEntry}; diff --git a/src/library/resolver.rs b/src/library/resolver.rs new file mode 100644 index 0000000..672df35 --- /dev/null +++ b/src/library/resolver.rs @@ -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 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 { + 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 { + 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, value: impl Into) { + let key = key.into(); + self.env.retain(|(k, _)| k != &key); + self.env.push((key, value.into())); + } + + pub fn resolve_dir(&self, nickname: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/library/table.rs b/src/library/table.rs new file mode 100644 index 0000000..f40d935 --- /dev/null +++ b/src/library/table.rs @@ -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, +} + +impl FpLibTable { + pub fn parse(source: &str) -> Result { + 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 { + 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 { + 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 { + 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 {} diff --git a/src/parse/board.rs b/src/parse/board.rs new file mode 100644 index 0000000..ddff05b --- /dev/null +++ b/src/parse/board.rs @@ -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 for BoardError { + fn from(e: ParseError) -> Self { Self::Parse(e) } +} + +#[derive(Debug, Clone)] +pub struct Board { + pub items: Vec, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone)] +pub struct Via { + pub at: Point, + pub size_mm: f64, + pub drill_mm: f64, + pub layers: Vec, + pub uuid: Option, +} + +#[derive(Debug, Clone)] +pub struct GrLine { + pub start: Point, + pub end: Point, + pub width_mm: f64, + pub layer: String, + pub uuid: Option, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone)] +pub struct GrPoly { + pub points: Vec, + pub width_mm: f64, + pub fill: bool, + pub layer: String, + pub uuid: Option, +} + +pub fn parse(source: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + find_list(node, "layer").and_then(|n| arg_str(n, 0)).map(str::to_string) +} + +fn uuid_str(node: &Node) -> Option { + 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 +} diff --git a/src/parse/dimension.rs b/src/parse/dimension.rs new file mode 100644 index 0000000..fcdf434 --- /dev/null +++ b/src/parse/dimension.rs @@ -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, + pub points: Vec, + pub height: f64, + pub arrow_length_mm: f64, + pub thickness_mm: f64, + pub extension_height: f64, + pub extension_offset: f64, + pub text: Option, +} + +pub fn parse_dimension(node: &Node) -> Option { + 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, + }) +} diff --git a/src/parse/footprint.rs b/src/parse/footprint.rs new file mode 100644 index 0000000..7554005 --- /dev/null +++ b/src/parse/footprint.rs @@ -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, + pub primitives: Vec, + pub pads: Vec, + pub texts: Vec, +} + +#[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, 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, + pub layers: Vec, + pub anchor_shape: AnchorShape, + pub primitives: Vec, +} + +#[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, + pub offset: Option<(f64, f64)>, +} + +pub fn parse_footprint(node: &Node) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + find_list(node, "layer").and_then(|n| arg_str(n, 0)).map(str::to_string) +} + +fn uuid_str(node: &Node) -> Option { + 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 +} diff --git a/src/parse/mod.rs b/src/parse/mod.rs new file mode 100644 index 0000000..78a8cee --- /dev/null +++ b/src/parse/mod.rs @@ -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}; diff --git a/src/parse/sexpr.rs b/src/parse/sexpr.rs new file mode 100644 index 0000000..050a9c2 --- /dev/null +++ b/src/parse/sexpr.rs @@ -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 { + 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 { + symbol(node).and_then(|s| s.parse::().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)?)) +} diff --git a/src/parse/text.rs b/src/parse/text.rs new file mode 100644 index 0000000..6f44b8e --- /dev/null +++ b/src/parse/text.rs @@ -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, +} + +#[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, +} + +pub fn parse_gr_text(node: &Node) -> Option { + 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 { + 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 { + 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 { + find_list(node, "layer").and_then(|n| arg_str(n, 0)).map(str::to_string) +} + +fn uuid_str(node: &Node) -> Option { + find_list(node, "uuid").and_then(|n| arg_str(n, 0)).map(str::to_string) +} diff --git a/src/parse/zone.rs b/src/parse/zone.rs new file mode 100644 index 0000000..1b18f4d --- /dev/null +++ b/src/parse/zone.rs @@ -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, + pub net_name: Option, + pub outline: Vec, + pub filled_polygons: Vec, + pub keepout: bool, +} + +#[derive(Debug, Clone)] +pub struct FilledPolygon { + pub layer: String, + pub points: Vec, +} + +pub fn parse_zone(node: &Node) -> Option { + 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 = 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 { + 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 +} diff --git a/src/render/dimension.rs b/src/render/dimension.rs new file mode 100644 index 0000000..26b7192 --- /dev/null +++ b/src/render/dimension.rs @@ -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)); +} diff --git a/src/render/footprint.rs b/src/render/footprint.rs new file mode 100644 index 0000000..6276c8c --- /dev/null +++ b/src/render/footprint.rs @@ -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 = 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 = pts.iter().map(|p| tf.apply(*p)).collect(); + sink.polygon(&world, style); + for p in &world { bounds.expand_point(*p); } +} diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..1fe512c --- /dev/null +++ b/src/render/mod.rs @@ -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>, + pub uuid_filter: Option>, + 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>> = 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>>, +) { + 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 { + 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)); +} diff --git a/src/render/palette.rs b/src/render/palette.rs new file mode 100644 index 0000000..840236c --- /dev/null +++ b/src/render/palette.rs @@ -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, 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), + } + } +} diff --git a/src/render/prim.rs b/src/render/prim.rs new file mode 100644 index 0000000..a5214c6 --- /dev/null +++ b/src/render/prim.rs @@ -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 = 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); + } +} diff --git a/src/sink/mod.rs b/src/sink/mod.rs new file mode 100644 index 0000000..a08b4bb --- /dev/null +++ b/src/sink/mod.rs @@ -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; diff --git a/src/sink/svg.rs b/src/sink/svg.rs new file mode 100644 index 0000000..6a74eaa --- /dev/null +++ b/src/sink/svg.rs @@ -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, + 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("\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, + "\n", + bb.min_x, bb.min_y, w, h, w, h + ); + out.push_str(&self.body); + out.push_str("\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("\n"); + } + let _ = write!(self.body, "\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("\n"); + self.layer_open = false; + self.active_layer = None; + } + } + + fn line(&mut self, a: Point, b: Point, style: &Style) { + let _ = write!( + self.body, + "\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, + "\n"); + } + + fn circle(&mut self, centre: Point, radius: f64, style: &Style) { + let _ = write!( + self.body, + "\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, + "\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, + "\ + {}\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("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + _ => out.push(ch), + } + } + out +}