//! A lightweight footprint preview. This is a comparison aid, not a KiCad renderer. use crate::sexpr::{self, Node}; use eframe::egui::{self, Color32, Pos2, Stroke, Vec2}; #[derive(Default)] pub struct Preview { paths: Vec, labels: Vec<([f32; 2], String)>, bounds: Option<[f32; 4]>, pub warnings: Vec, } struct Path { points: Vec<[f32; 2]>, closed: bool, fill: bool, color: Color32, } const PAD: Color32 = Color32::from_rgb(208, 164, 78); const OUTLINE: Color32 = Color32::from_rgb(120, 194, 224); const HOLE: Color32 = Color32::from_rgb(30, 33, 38); fn number(n: &Node, index: usize) -> Option { n.children() .get(index)? .atom()? .parse::() .ok() .filter(|v| v.is_finite() && v.abs() <= 100_000.0) } fn xy(n: &Node) -> Option<[f32; 2]> { Some([number(n, 1)?, number(n, 2)?]) } fn point(n: &Node, field: &str) -> Option<[f32; 2]> { xy(n.field(field)?) } fn rotate(p: [f32; 2], center: [f32; 2], angle: f32) -> [f32; 2] { let (s, c) = angle.to_radians().sin_cos(); // KiCad pad rotation is counter-clockwise in its downward-positive Y coordinates. [ center[0] + p[0] * c + p[1] * s, center[1] - p[0] * s + p[1] * c, ] } fn ellipse(center: [f32; 2], size: [f32; 2], angle: f32) -> Vec<[f32; 2]> { (0..64) .map(|i| { let a = i as f32 * std::f32::consts::TAU / 64.0; rotate( [a.cos() * size[0] / 2.0, a.sin() * size[1] / 2.0], center, angle, ) }) .collect() } fn rounded(center: [f32; 2], size: [f32; 2], radius: f32, angle: f32) -> Vec<[f32; 2]> { let mut points = Vec::new(); for (cx, cy, start) in [ (1.0, 1.0, 0.0), (-1.0, 1.0, 90.0), (-1.0, -1.0, 180.0), (1.0, -1.0, 270.0), ] { for i in 0..=12 { let a = (start + i as f32 * 7.5).to_radians(); points.push(rotate( [ cx * (size[0] / 2.0 - radius) + radius * a.cos(), cy * (size[1] / 2.0 - radius) + radius * a.sin(), ], center, angle, )); } } points } impl Preview { pub fn from_raw(raw: &str) -> Self { let mut preview = Self::default(); let root = match sexpr::parse(raw) { Ok(n) => n, Err(e) => { preview .warnings .push(format!("Cannot parse footprint: {e}")); return preview; } }; if !matches!(root.head(), Some("footprint" | "module")) { preview .warnings .push("Expected a footprint or module".into()); return preview; } for n in root.children() { let valid = match n.head() { Some("pad") => preview.pad(n), Some("fp_line") => preview.line(n), Some("fp_rect") => preview.rect(n), Some("fp_circle") => preview.circle(n), Some("fp_poly") => preview.poly(n), Some("fp_arc") => preview.arc(n), Some("fp_curve") => { preview .warnings .push("Bezier curve omitted from preview".into()); true } _ => true, }; if !valid { preview.warnings.push(format!( "Invalid {} omitted from preview", n.head().unwrap_or("geometry") )); } } if preview.bounds.is_none() { preview .warnings .push("No drawable footprint geometry".into()); } preview } fn add(&mut self, points: Vec<[f32; 2]>, closed: bool, fill: bool, color: Color32) { if points.is_empty() || points .iter() .flatten() .any(|v| !v.is_finite() || v.abs() > 200_000.0) { return; } for p in &points { let b = self.bounds.get_or_insert([p[0], p[1], p[0], p[1]]); b[0] = b[0].min(p[0]); b[1] = b[1].min(p[1]); b[2] = b[2].max(p[0]); b[3] = b[3].max(p[1]); } self.paths.push(Path { points, closed, fill, color, }); } fn pad(&mut self, n: &Node) -> bool { let Some(at) = n.field("at") else { return false; }; let Some(center) = xy(at) else { return false }; let angle = if at.children().len() > 3 { let Some(a) = number(at, 3) else { return false }; a } else { 0.0 }; let Some(size) = point(n, "size").filter(|s| s[0] > 0.0 && s[1] > 0.0) else { return false; }; let shape = n.children().get(3).and_then(Node::atom).unwrap_or(""); let points = match shape { "circle" => ellipse(center, size, angle), "rect" => rounded(center, size, 0.0, angle), "oval" => rounded(center, size, size[0].min(size[1]) / 2.0, angle), "roundrect" => { let ratio = n .field("roundrect_rratio") .and_then(|r| number(r, 1)) .unwrap_or(0.25); if !(0.0..=0.5).contains(&ratio) { return false; } rounded(center, size, size[0].min(size[1]) * ratio, angle) } _ => { self.warnings.push(format!( "Unsupported pad shape '{shape}'; bounding rectangle shown" )); rounded(center, size, 0.0, angle) } }; self.add(points, true, true, PAD); if let Some(drill) = n.field("drill") { let oval = drill.children().get(1).and_then(Node::atom) == Some("oval"); let d = number(drill, if oval { 2 } else { 1 }); if let Some(d) = d.filter(|d| *d > 0.0) { let dy = if oval { number(drill, 3).unwrap_or(d) } else { d }; if dy > 0.0 { let offset = drill.field("offset").and_then(xy).unwrap_or([0.0, 0.0]); self.add( rounded( rotate(offset, center, angle), [d, dy], d.min(dy) / 2.0, angle, ), true, true, HOLE, ); } else { self.warnings.push("Invalid drill omitted".into()); } } else { self.warnings.push("Invalid drill omitted".into()); } } self.labels .push((center, n.value().unwrap_or("").to_owned())); true } fn line(&mut self, n: &Node) -> bool { let (Some(a), Some(b)) = (point(n, "start"), point(n, "end")) else { return false; }; self.add(vec![a, b], false, false, OUTLINE); true } fn rect(&mut self, n: &Node) -> bool { let (Some(a), Some(b)) = (point(n, "start"), point(n, "end")) else { return false; }; self.add(vec![a, [b[0], a[1]], b, [a[0], b[1]]], true, false, OUTLINE); true } fn circle(&mut self, n: &Node) -> bool { let (Some(c), Some(e)) = (point(n, "center"), point(n, "end")) else { return false; }; let d = 2.0 * (e[0] - c[0]).hypot(e[1] - c[1]); self.add(ellipse(c, [d, d], 0.0), true, false, OUTLINE); true } fn poly(&mut self, n: &Node) -> bool { let Some(pts) = n.field("pts") else { return false; }; let Some(points) = pts .children() .iter() .skip(1) .map(xy) .collect::>>() else { return false; }; if points.len() < 2 { return false; } self.add(points, true, false, OUTLINE); true } fn arc(&mut self, n: &Node) -> bool { let (Some(a), Some(m), Some(b)) = (point(n, "start"), point(n, "mid"), point(n, "end")) else { self.warnings.push("Legacy arc omitted from preview".into()); return true; }; let determinant = 2.0 * (a[0] * (m[1] - b[1]) + m[0] * (b[1] - a[1]) + b[0] * (a[1] - m[1])); if determinant.abs() < 1e-6 { return false; } let q = |p: [f32; 2]| p[0] * p[0] + p[1] * p[1]; let center = [ (q(a) * (m[1] - b[1]) + q(m) * (b[1] - a[1]) + q(b) * (a[1] - m[1])) / determinant, (q(a) * (b[0] - m[0]) + q(m) * (a[0] - b[0]) + q(b) * (m[0] - a[0])) / determinant, ]; let angle = |p: [f32; 2]| (p[1] - center[1]).atan2(p[0] - center[0]); let start = angle(a); let end = (angle(b) - start).rem_euclid(std::f32::consts::TAU); let middle = (angle(m) - start).rem_euclid(std::f32::consts::TAU); let sweep = if middle <= end { end } else { end - std::f32::consts::TAU }; let radius = (a[0] - center[0]).hypot(a[1] - center[1]); self.add( (0..=64) .map(|i| { let t = start + sweep * i as f32 / 64.0; [center[0] + radius * t.cos(), center[1] + radius * t.sin()] }) .collect(), false, false, OUTLINE, ); true } pub fn span(&self) -> [f32; 2] { let b = self.bounds.unwrap_or([-0.5, -0.5, 0.5, 0.5]); [(b[2] - b[0]).max(0.001), (b[3] - b[1]).max(0.001)] } pub fn paint(&self, ui: &mut egui::Ui, scale: f32) -> egui::Response { let (rect, response) = ui.allocate_exact_size( Vec2::new(ui.available_width().max(1.0), 300.0), egui::Sense::hover(), ); let painter = ui.painter_at(rect); painter.rect_filled(rect, 4.0, HOLE); let b = self.bounds.unwrap_or([-0.5, -0.5, 0.5, 0.5]); let scale = if scale.is_finite() && scale > 0.0 { scale.min(100_000.0) } else { 1.0 }; let transform = |p: [f32; 2]| { Pos2::new( rect.center().x + (p[0] - (b[0] + b[2]) / 2.0) * scale, rect.center().y + (p[1] - (b[1] + b[3]) / 2.0) * scale, ) }; for path in &self.paths { let mut points: Vec<_> = path.points.iter().copied().map(transform).collect(); if path.fill { painter.add(egui::Shape::convex_polygon( points, path.color, Stroke::NONE, )); } else { if path.closed { points.push(points[0]); } painter.add(egui::Shape::line(points, Stroke::new(1.5, path.color))); } } for (p, label) in &self.labels { painter.text( transform(*p), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), Color32::WHITE, ); } let span = self.span(); painter.text( rect.left_bottom() + Vec2::new(8.0, -8.0), egui::Align2::LEFT_BOTTOM, format!("{:.2} × {:.2} mm", span[0], span[1]), egui::FontId::proportional(12.0), Color32::WHITE, ); response } } pub fn fit_scale(left: &Preview, right: &Preview, width: f32, height: f32) -> f32 { let a = left.span(); let b = right.span(); ((width - 32.0).max(1.0) / a[0].max(b[0])) .min((height - 40.0).max(1.0) / a[1].max(b[1])) .clamp(0.0001, 100_000.0) } #[cfg(test)] mod tests { use super::*; #[test] fn rotated_asymmetric_pad_and_unicode() { let p = Preview::from_raw( r#"(footprint "µ (quoted)" (pad "Ω" smd rect (at 2 3 90) (size 4 2)))"#, ); assert!(p.warnings.is_empty()); assert_eq!(p.labels[0].1, "Ω"); let span = p.span(); assert!((span[0] - 2.0).abs() < 0.001); assert!((span[1] - 4.0).abs() < 0.001); } #[test] fn malformed_and_nonfinite_geometry_is_safe() { for input in [ "(footprint", "(footprint x (pad 1 smd rect (at NaN 0) (size 2 2)))", "(footprint x (pad 1 smd rect (at 0 0) (size -1 2)))", "(footprint x (fp_line (start inf 0) (end 1 1)))", ] { let p = Preview::from_raw(input); assert!(!p.warnings.is_empty()); assert!(p.span().iter().all(|n| n.is_finite() && *n > 0.0)); } } #[test] fn unknown_shapes_and_drill_offsets_are_visible() { let p = Preview::from_raw( "(footprint x (pad 1 thru_hole custom (at 0 0) (size 2 2) (drill 1 (offset 0.2 0))))", ); assert!(p.warnings.iter().any(|w| w.contains("custom"))); assert_eq!(p.paths.len(), 2); } #[test] fn outlines_and_arcs_have_expected_bounds() { let p = Preview::from_raw( "(footprint x (fp_rect (start -2 -3) (end 2 3)) (fp_circle (center 0 0) (end 1 0)) (fp_arc (start 1 0) (mid 0 1) (end -1 0)) (fp_poly (pts (xy 0 0) (xy 1 0) (xy 0 1))))", ); assert!(p.warnings.is_empty()); assert_eq!(p.span(), [4.0, 6.0]); assert_eq!(p.paths.len(), 4); } #[test] fn renders_without_a_display() { let p = Preview::from_raw("(footprint x (pad 1 smd oval (at 0 0 45) (size 2 4)))"); let ctx = egui::Context::default(); let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { p.paint(ui, fit_scale(&p, &p, 300.0, 300.0)); }); assert!(!output.shapes.is_empty()); output.textures_delta.clear(); } }