Release v0.2.0: native GUI and managed KiCad libraries
This commit is contained in:
1 parent
0ed28c03fb
commit
c91f2ad67d
24 files changed
+9380
-527
No files matched your search
+418
@@ -0,0 +1,418 @@
|
||||
//! 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<Path>,
|
||||
labels: Vec<([f32; 2], String)>,
|
||||
bounds: Option<[f32; 4]>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
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<f32> {
|
||||
n.children()
|
||||
.get(index)?
|
||||
.atom()?
|
||||
.parse::<f32>()
|
||||
.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::<Option<Vec<_>>>()
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user