app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #348: kicad-lane: 1.0.87, simulate noise and Monte Carlo, turned bodies, engine 0.20.0
d90033b
12d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
//! 3D bodies (the EDA Bridge Standard's `bodies` companion): each part's 3D model as a box, its
//! footprint rectangle on the board and its height range above the surface it sits on, in mm.
//!
//! KiCad keeps no heights in the board: they are in the model files a footprint names. This reads a
//! STEP file's topological vertices and its circles (each circle's real extent in its own plane, so a
//! radial capacitor is not made a radius taller) or a VRML file's points (KiCad's 0.1 inch unit), then
//! places the box the way KiCad's 3D viewer does (`render_3d_opengl.cpp`):
//! model = T(offset) * Rz(-rz) * Ry(-ry) * Rx(-rx) * S(scale), in a y-up space;
//! board = T(position) * Rz(orientation) * [bottom: (x, y, z) -> (x, -y, -z)] * model.
//! Nothing here talks to KiCad; the caller resolves the model paths (`${KICAD10_3DMODEL_DIR}` etc.).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::model_check::{parse_sexpr, Sx};
/// An axis-aligned box: min and max corners, mm.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bounds {
pub min: [f64; 3],
pub max: [f64; 3],
}
impl Bounds {
fn empty() -> Self {
Bounds { min: [f64::MAX; 3], max: [f64::MIN; 3] }
}
fn add(&mut self, p: [f64; 3]) {
for i in 0..3 {
self.min[i] = self.min[i].min(p[i]);
self.max[i] = self.max[i].max(p[i]);
}
}
fn is_empty(&self) -> bool {
self.min[0] > self.max[0]
}
fn corners(&self) -> Vec<[f64; 3]> {
let mut out = Vec::with_capacity(8);
for &x in &[self.min[0], self.max[0]] {
for &y in &[self.min[1], self.max[1]] {
for &z in &[self.min[2], self.max[2]] {
out.push([x, y, z]);
}
}
}
out
}
}
// -- STEP ---------------------------------------------------------------------------------------
/// The numbers in a STEP argument list such as `'',(0.8,0.4,0.)`.
fn numbers(s: &str) -> Vec<f64> {
s.split(|c: char| c == ',' || c == '(' || c == ')' || c.is_whitespace()).filter_map(|t| t.parse::<f64>().ok()).collect()
}
/// The `#n` references in a STEP argument list, in order.
fn refs(s: &str) -> Vec<u64> {
s.split(|c: char| !(c.is_ascii_digit() || c == '#')).filter_map(|t| t.strip_prefix('#')).filter_map(|t| t.parse().ok()).collect()
}
/// The bounds of a STEP model in mm: its vertices, and every circular edge swept between its two
/// vertices in its own plane (a cylinder's side bulges past its vertices; a trimmed arc of a larger
/// circle does not reach the whole circle). Falls back to every point when the file has no vertices.
/// Ignores assembly placements (KiCad's library models are one body in one frame).
pub fn step_bounds(text: &str) -> Option<Bounds> {
let scale = if text.contains("CONVERSION_BASED_UNIT('INCH'") || text.contains("CONVERSION_BASED_UNIT ( 'INCH'") {
25.4
} else if text.contains("SI_UNIT($,.METRE.)") || text.contains("SI_UNIT ( $ , .METRE. )") {
1000.0
} else {
1.0
};
let mut points: HashMap<u64, [f64; 3]> = HashMap::new();
let mut dirs: HashMap<u64, [f64; 3]> = HashMap::new();
let mut axes: HashMap<u64, (u64, Option<u64>, Option<u64>)> = HashMap::new();
let mut vertices: HashMap<u64, u64> = HashMap::new();
let mut circles: HashMap<u64, (u64, f64)> = HashMap::new();
// (start vertex, end vertex, curve, same sense as the curve)
let mut edges: Vec<(u64, u64, u64, bool)> = Vec::new();
// statements end at ';' outside a quoted string
let mut stmt = String::new();
let mut quoted = false;
let data = text.find("DATA;").map(|i| &text[i + 5..]).unwrap_or(text);
for c in data.chars() {
if c == '\'' {
quoted = !quoted;
}
if c == ';' && !quoted {
let st = stmt.trim();
if let Some(rest) = st.strip_prefix('#') {
if let Some((id, body)) = rest.split_once('=') {
if let Ok(id) = id.trim().parse::<u64>() {
let body = body.trim();
let (kind, args) = body.split_once('(').map(|(k, a)| (k.trim(), a)).unwrap_or((body, ""));
// skip the entity's name string before reading numbers
let after_name = args.split_once("',").map(|(_, r)| r).unwrap_or(args);
match kind {
"CARTESIAN_POINT" => {
let n = numbers(after_name);
if n.len() >= 3 {
points.insert(id, [n[0], n[1], n[2]]);
}
}
"DIRECTION" => {
let n = numbers(after_name);
if n.len() >= 3 {
dirs.insert(id, [n[0], n[1], n[2]]);
}
}
"AXIS2_PLACEMENT_3D" => {
let r = refs(after_name);
if let Some(&loc) = r.first() {
axes.insert(id, (loc, r.get(1).copied(), r.get(2).copied()));
}
}
"VERTEX_POINT" => {
if let Some(&p) = refs(after_name).first() {
vertices.insert(id, p);
}
}
"CIRCLE" => {
let r = refs(after_name);
let rad = after_name.rsplit(',').next().and_then(|t| t.trim().trim_end_matches(')').trim().parse::<f64>().ok());
if let (Some(&ax), Some(rad)) = (r.first(), rad) {
circles.insert(id, (ax, rad));
}
}
"EDGE_CURVE" => {
let r = refs(after_name);
if r.len() >= 3 {
edges.push((r[0], r[1], r[2], !after_name.contains(".F.")));
}
}
_ => {}
}
}
}
}
stmt.clear();
continue;
}
stmt.push(c);
}
let mut b = Bounds::empty();
for p in vertices.values() {
if let Some(p) = points.get(p) {
b.add(*p);
}
}
let sub = |a: [f64; 3], c: [f64; 3]| [a[0] - c[0], a[1] - c[1], a[2] - c[2]];
let dot = |a: [f64; 3], c: [f64; 3]| a[0] * c[0] + a[1] * c[1] + a[2] * c[2];
let unit = |a: [f64; 3]| { let l = dot(a, a).sqrt().max(1e-12); [a[0] / l, a[1] / l, a[2] / l] };
for (v1, v2, curve, same) in &edges {
let Some((ax, r)) = circles.get(curve) else { continue };
let Some((loc, dir, refd)) = axes.get(ax) else { continue };
let Some(c) = points.get(loc).copied() else { continue };
// the circle's own frame: x along its reference direction, y = normal x x
let n = unit(dir.and_then(|d| dirs.get(&d)).copied().unwrap_or([0.0, 0.0, 1.0]));
let mut x = refd.and_then(|d| dirs.get(&d)).copied().unwrap_or(if n[2].abs() < 0.9 { [0.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0] });
let k = dot(x, n);
x = unit([x[0] - k * n[0], x[1] - k * n[1], x[2] - k * n[2]]);
let y = [n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]];
let angle = |p: [f64; 3]| { let d = sub(p, c); dot(d, y).atan2(dot(d, x)) };
let (p1, p2) = (vertices.get(v1).and_then(|p| points.get(p)).copied(), vertices.get(v2).and_then(|p| points.get(p)).copied());
// the edge runs counterclockwise about the normal from its start to its end when it has the
// curve's sense, else from its end to its start; one vertex twice is the whole circle
let (from, sweep) = match (p1, p2) {
(Some(a), Some(e)) if v1 != v2 => {
let (s0, s1) = if *same { (angle(a), angle(e)) } else { (angle(e), angle(a)) };
(s0, (s1 - s0).rem_euclid(std::f64::consts::TAU))
}
_ => (0.0, std::f64::consts::TAU),
};
let steps = ((sweep.to_degrees() / 2.0).ceil() as usize).max(1);
for i in 0..=steps {
let t = from + sweep * i as f64 / steps as f64;
let (sn, cs) = t.sin_cos();
b.add([c[0] + r * (cs * x[0] + sn * y[0]), c[1] + r * (cs * x[1] + sn * y[1]), c[2] + r * (cs * x[2] + sn * y[2])]);
}
}
if b.is_empty() {
for p in points.values() {
b.add(*p);
}
}
if b.is_empty() {
return None;
}
for i in 0..3 {
b.min[i] *= scale;
b.max[i] *= scale;
}
Some(b)
}
/// The bounds of a VRML model in mm: every `point [ ... ]` list, in KiCad's VRML unit (0.1 inch).
pub fn wrl_bounds(text: &str) -> Option<Bounds> {
let mut b = Bounds::empty();
let mut rest = text;
while let Some(i) = rest.find("point") {
rest = &rest[i + 5..];
let Some(open) = rest.find('[') else { break };
if !rest[..open].trim().is_empty() {
continue;
}
let Some(close) = rest[open..].find(']') else { break };
let n = numbers(&rest[open + 1..open + close]);
for t in n.chunks_exact(3) {
b.add([t[0] * 2.54, t[1] * 2.54, t[2] * 2.54]);
}
rest = &rest[open + close..];
}
(!b.is_empty()).then_some(b)
}
// -- placement ----------------------------------------------------------------------------------
/// One `(model ...)` of a footprint.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelRef {
pub path: String,
pub offset: [f64; 3],
pub scale: [f64; 3],
pub rotate: [f64; 3],
pub hidden: bool,
}
fn xyz(m: &Sx, tag: &str, default: [f64; 3]) -> [f64; 3] {
let Some(x) = m.find(tag).and_then(|t| t.find("xyz")) else { return default };
let n = |i| x.atom_at(i).and_then(|a| a.parse::<f64>().ok());
match (n(1), n(2), n(3)) {
(Some(a), Some(b), Some(c)) => [a, b, c],
_ => default,
}
}
pub fn model_refs(fp: &Sx) -> Vec<ModelRef> {
fp.find_all("model").map(|m| ModelRef {
path: m.value().unwrap_or("").to_string(),
offset: xyz(m, "offset", [0.0; 3]),
scale: xyz(m, "scale", [1.0; 3]),
rotate: xyz(m, "rotate", [0.0; 3]),
hidden: m.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || m.as_list().unwrap_or(&[]).iter().any(|a| a.as_atom() == Some("hide")),
}).collect()
}
fn rot(p: [f64; 3], axis: usize, deg: f64) -> [f64; 3] {
let (s, c) = deg.to_radians().sin_cos();
let (i, j) = match axis { 0 => (1, 2), 1 => (2, 0), _ => (0, 1) };
let mut q = p;
q[i] = p[i] * c - p[j] * s;
q[j] = p[i] * s + p[j] * c;
q
}
/// The model's box in the footprint's own frame (y up, z up from the surface): KiCad's model transform.
pub fn in_footprint(b: &Bounds, m: &ModelRef) -> Bounds {
let mut out = Bounds::empty();
for p in b.corners() {
let mut q = [p[0] * m.scale[0], p[1] * m.scale[1], p[2] * m.scale[2]];
q = rot(q, 0, -m.rotate[0]);
q = rot(q, 1, -m.rotate[1]);
q = rot(q, 2, -m.rotate[2]);
out.add([q[0] + m.offset[0], q[1] + m.offset[1], q[2] + m.offset[2]]);
}
out
}
/// A footprint-frame box placed on the board (y up): its footprint rectangle and its height range
/// above the surface it sits on.
pub fn on_board(b: &Bounds, at: (f64, f64), angle: f64, bottom: bool) -> ([f64; 4], [f64; 2]) {
let mut x = (f64::MAX, f64::MIN);
let mut y = (f64::MAX, f64::MIN);
for p in b.corners() {
let q = if bottom { [p[0], -p[1], -p[2]] } else { p };
let r = rot(q, 2, angle);
x = (x.0.min(r[0] + at.0), x.1.max(r[0] + at.0));
y = (y.0.min(r[1] + at.1), y.1.max(r[1] + at.1));
}
([x.0, y.0, x.1, y.1], [b.min[2], b.max[2]])
}
/// The footprint rectangle of a footprint-frame box placed on the board (y up), as its four turned corners: what a
/// part not at a multiple of 90° really covers (its `on_board` extent is the bounding box of these).
pub fn on_board_corners(b: &Bounds, at: (f64, f64), angle: f64, bottom: bool) -> [[f64; 2]; 4] {
let (x0, y0, x1, y1) = (b.min[0], b.min[1], b.max[0], b.max[1]);
[[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(|[x, y]| {
let q = if bottom { [x, -y, 0.0] } else { [x, y, 0.0] };
let r = rot(q, 2, angle);
[r[0] + at.0, r[1] + at.1]
})
}
// -- paths --------------------------------------------------------------------------------------
/// Expand `${VAR}` in a model path: the 3D model dirs of every KiCad version name the install's
/// 3dmodels folder, KIPRJMOD the board's folder, anything else the variables given (KiCad's own
/// settings, then the process environment).
pub fn expand(path: &str, models_dir: Option<&Path>, project_dir: &Path, vars: &HashMap<String, String>) -> Option<String> {
let mut out = String::new();
let mut rest = path;
while let Some(i) = rest.find("${") {
out.push_str(&rest[..i]);
let after = &rest[i + 2..];
let end = after.find('}')?;
let name = &after[..end];
let val = if name == "KIPRJMOD" {
Some(project_dir.to_string_lossy().to_string())
} else if let Some(v) = vars.get(name) {
Some(v.clone())
} else if (name.ends_with("_3DMODEL_DIR") && name.starts_with("KICAD")) || name == "KISYS3DMOD" {
models_dir.map(|d| d.to_string_lossy().to_string())
} else {
std::env::var(name).ok()
};
out.push_str(&val?);
rest = &after[end + 1..];
}
out.push_str(rest);
Some(out)
}
/// The file KiCad would load for a model path: the path itself, or the same name as STEP when a
/// VRML is named but only the STEP ships (KiCad 10's library), or the other way round.
pub fn find_model(expanded: &str, project_dir: &Path) -> Option<PathBuf> {
let p = PathBuf::from(expanded);
let p = if p.is_relative() { project_dir.join(p) } else { p };
if p.is_file() {
return Some(p);
}
let alts: &[&str] = match p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
Some("wrl") | Some("wrz") => &["step", "stp", "STEP", "STP"],
Some("step") | Some("stp") => &["wrl", "WRL"],
_ => &["step", "stp", "wrl"],
};
alts.iter().map(|e| p.with_extension(e)).find(|q| q.is_file())
}
/// Bounds of one model file, by its extension.
pub fn file_bounds(path: &Path) -> Result<Bounds, String> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let bytes = std::fs::read(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
let text = String::from_utf8_lossy(&bytes);
let b = match ext.as_str() {
"step" | "stp" => step_bounds(&text),
"wrl" => wrl_bounds(&text),
other => return Err(format!("a .{other} model is not read (STEP and VRML are)")),
};
b.ok_or_else(|| format!("no geometry found in {}", path.display()))
}
/// Every part's bodies as the standard's boxes, from the board text. `load` returns a model file's
/// bounds (cached by the caller) or why it could not.
pub fn bodies(board_text: &str, y_up: bool, resolve: &mut dyn FnMut(&str) -> Result<(Bounds, String), String>) -> Result<Value, String> {
let root = parse_sexpr(board_text).ok_or("empty board text")?;
let mut out = Vec::new();
let mut missing = Vec::new();
let mut parts = 0;
for fp in root.find_all("footprint") {
parts += 1;
let reference = fp.find_all("property").find(|p| p.value() == Some("Reference")).and_then(|p| p.atom_at(2))
.or_else(|| fp.find_all("fp_text").find(|t| t.value() == Some("reference")).and_then(|t| t.atom_at(2)))
.unwrap_or("").to_string();
let at = fp.find("at");
let num = |i| at.and_then(|a| a.atom_at(i)).and_then(|s| s.parse::<f64>().ok());
let (x, y, angle) = (num(1).unwrap_or(0.0), -num(2).unwrap_or(0.0), num(3).unwrap_or(0.0));
let bottom = fp.find("layer").and_then(Sx::value) == Some("B.Cu");
let side = if bottom { "bottom" } else { "top" };
let models: Vec<ModelRef> = model_refs(fp).into_iter().filter(|m| !m.hidden).collect();
if models.is_empty() {
missing.push(json!({"ref": reference, "why": "no 3D model"}));
continue;
}
for m in models {
if m.path.starts_with("kicad-embed://") {
missing.push(json!({"ref": reference, "model": m.path, "why": "an embedded model (kicad-embed://) is not read yet"}));
continue;
}
match resolve(&m.path) {
Ok((b, file)) => {
let local = in_footprint(&b, &m);
let (r, z) = on_board(&local, (x, y), angle, bottom);
let fy = |v: f64| if y_up { v } else { -v };
let (y0, y1) = if y_up { (r[1], r[3]) } else { (fy(r[3]), fy(r[1])) };
let r6 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;
let mut row = json!({"ref": reference, "side": side, "x0": r6(r[0]), "y0": r6(y0), "x1": r6(r[2]), "y1": r6(y1),
"z0": r6(z[0]), "z1": r6(z[1]), "heightMm": r6(z[1].max(0.0)), "model": m.path, "file": file});
// a part turned off the 90° grid: its real rectangle, so the checks don't compare the bigger bounding
// box (standard #26)
if (angle / 90.0 - (angle / 90.0).round()).abs() > 1e-6 {
let cs = on_board_corners(&local, (x, y), angle, bottom);
row["corners"] = json!(cs.iter().map(|c| [r6(c[0]), r6(fy(c[1]))]).collect::<Vec<_>>());
}
out.push(row);
}
Err(why) => missing.push(json!({"ref": reference, "model": m.path, "why": why})),
}
}
}
Ok(json!({"bodies": out, "noBody": missing, "parts": parts}))
}
#[cfg(test)]
mod tests {
use super::*;
const STEP: &str = "ISO-10303-21;\nDATA;\n#1=CARTESIAN_POINT('',(-0.8,-0.4,0.));\n#2=VERTEX_POINT('',#1);\n#3=CARTESIAN_POINT('',(0.8,0.4,0.45));\n#4=VERTEX_POINT('',#3);\n\
#5=CARTESIAN_POINT('',(9.,9.,9.));\n#6=CARTESIAN_POINT('Origin',(0.,0.,5.));\n#7=DIRECTION('',(0.,0.,1.));\n#8=DIRECTION('',(1.,0.,0.));\n#9=AXIS2_PLACEMENT_3D('',#6,#7,#8);\n#10=CIRCLE('',#9,1.5);\n\
#11=CARTESIAN_POINT('',(1.5,0.,5.));\n#12=VERTEX_POINT('',#11);\n#13=EDGE_CURVE('',#12,#12,#10,.T.);\n\
#14=CIRCLE('',#9,9.);\n#15=CARTESIAN_POINT('',(9.,0.,5.));\n#16=VERTEX_POINT('',#15);\n#17=CARTESIAN_POINT('',(0.,9.,5.));\n#18=VERTEX_POINT('',#17);\n#19=EDGE_CURVE('',#18,#16,#14,.F.);\nENDSEC;\n";
#[test]
fn step_bounds_are_vertices_and_circles_in_their_plane() {
let b = step_bounds(STEP).unwrap();
// the stray control point (9, 9, 9) is not a vertex; the full circle at z 5 of radius 1.5 lies
// flat; the quarter arc of radius 9 from (9, 0) to (0, 9) (an edge against the curve's sense,
// so the short way) reaches no further than its ends
assert!((b.min[0] + 1.5).abs() < 1e-9 && (b.min[1] + 1.5).abs() < 1e-9 && b.min[2] == 0.0, "{b:?}");
assert!((b.max[0] - 9.0).abs() < 1e-9 && (b.max[1] - 9.0).abs() < 1e-9 && b.max[2] == 5.0, "{b:?}");
let inch = STEP.replace("DATA;", "CONVERSION_BASED_UNIT('INCH',#99);\nDATA;");
assert!((step_bounds(&inch).unwrap().max[2] - 127.0).abs() < 1e-6);
}
#[test]
fn wrl_points_are_in_tenths_of_an_inch() {
let b = wrl_bounds("#VRML V2.0 utf8\nShape { geometry IndexedFaceSet { coord Coordinate { point [ 0 0 0, 1 2 0.5 ] } } }").unwrap();
assert_eq!(b.max, [2.54, 5.08, 1.27]);
}
#[test]
fn a_body_is_placed_turned_and_flipped_like_kicads_3d_viewer() {
// a 2 x 1 x 0.5 body offset 1 mm to +x in its footprint
let b = Bounds { min: [-1.0, -0.5, 0.0], max: [1.0, 0.5, 0.5] };
let m = ModelRef { path: String::new(), offset: [1.0, 0.0, 0.0], scale: [1.0; 3], rotate: [0.0; 3], hidden: false };
let local = in_footprint(&b, &m);
assert_eq!(local.min, [0.0, -0.5, 0.0]);
let (r, z) = on_board(&local, (10.0, -10.0), 90.0, false);
// turned 90 counterclockwise: the body now reaches from the origin up +y
let r: Vec<f64> = r.iter().map(|v| (v * 1e9).round() / 1e9).collect();
assert_eq!(r, vec![9.5, -10.0, 10.5, -8.0]);
assert_eq!(z, [0.0, 0.5]);
// on the bottom the footprint is mirrored top to bottom in its own frame: an offset in +y flips
let m2 = ModelRef { offset: [0.0, 1.0, 0.0], ..m };
let (r, _) = on_board(&in_footprint(&b, &m2), (0.0, 0.0), 0.0, true);
assert_eq!(r, [-1.0, -1.5, 1.0, -0.5]);
}
#[test]
fn model_paths_expand_like_kicad_and_fall_back_to_the_step() {
let vars = HashMap::from([("MYLIB".to_string(), "D:/libs".to_string())]);
let models = Path::new("C:/KiCad/share/kicad/3dmodels");
let proj = Path::new("C:/p");
assert_eq!(expand("${KICAD8_3DMODEL_DIR}/R.3dshapes/R.wrl", Some(models), proj, &vars).unwrap(), "C:/KiCad/share/kicad/3dmodels/R.3dshapes/R.wrl");
assert_eq!(expand("${KIPRJMOD}/m.step", None, proj, &vars).unwrap(), "C:/p/m.step");
assert_eq!(expand("${MYLIB}/x.step", None, proj, &vars).unwrap(), "D:/libs/x.step");
assert!(expand("${NOPE_NOT_SET_ANYWHERE}/x.step", None, proj, &vars).is_none());
let dir = std::env::temp_dir().join(format!("kb-bodies-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("R.step"), STEP).unwrap();
assert_eq!(find_model(&dir.join("R.wrl").to_string_lossy(), &dir), Some(dir.join("R.step")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_part_turned_45_degrees_covers_its_rectangle_not_the_bounding_box() {
let mut b = Bounds::empty();
b.add([-1.0, -0.5, 0.0]);
b.add([1.0, 0.5, 0.6]);
let cs = on_board_corners(&b, (10.0, 20.0), 45.0, false);
let (r, _) = on_board(&b, (10.0, 20.0), 45.0, false);
// the corners lie inside the bounding box and touch each of its sides
for c in cs {
assert!(c[0] >= r[0] - 1e-9 && c[0] <= r[2] + 1e-9 && c[1] >= r[1] - 1e-9 && c[1] <= r[3] + 1e-9);
}
// the rectangle keeps its 2 x 1 size: two neighbouring corners are 2 mm apart, the next pair 1 mm
let d = |a: [f64; 2], b: [f64; 2]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt();
assert!((d(cs[0], cs[1]) - 2.0).abs() < 1e-9 && (d(cs[1], cs[2]) - 1.0).abs() < 1e-9);
// the bounding box is larger than 2 x 1 at 45°
assert!(r[2] - r[0] > 2.0 && r[3] - r[1] > 1.0);
}
}