app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
//! What the `nets` pictures are painted with: text from the program's own font atlas, smooth-edged lines, discs and
//! rings, and a coverage layer that fills copper in a colour and a pattern.
//!
//! Every confidence level has a hue and a pattern, so the levels can be told apart without colour: the hues are the
//! Okabe-Ito set, picked to stay apart for colour-blind viewers, and the patterns are coarse enough (a period of ten
//! pixels at 1080p) to survive video compression. Tracks are too thin to carry a pattern, so the weaker levels are
//! drawn dashed.
use image::{Rgb, RgbImage};
use std::collections::HashMap;
use std::sync::OnceLock;
pub type C3 = [u8; 3];
pub const WHITE: C3 = [255, 255, 255];
pub const INK: C3 = [12, 14, 18];
pub const PAPER: C3 = [18, 21, 26];
pub const SOFT: C3 = [196, 202, 210];
// ---------------------------------------------------------------- text
struct Glyph { x: f64, w: f64, left: f64, adv: f64 }
pub struct Font { w: usize, h: usize, sat: Vec<f64>, size: f64, glyphs: HashMap<char, Glyph> }
impl Font {
fn load(png: &[u8], table: &str) -> Font {
let a = image::load_from_memory(png).expect("label font atlas").to_luma8();
let (w, h) = (a.width() as usize, a.height() as usize);
let mut sat = vec![0f64; (w + 1) * (h + 1)];
for y in 0..h { let mut row = 0f64; for x in 0..w { row += a.get_pixel(x as u32, y as u32)[0] as f64; sat[(y + 1) * (w + 1) + x + 1] = sat[y * (w + 1) + x + 1] + row; } }
let t: serde_json::Value = serde_json::from_str(table).expect("label font table");
let glyphs = t["glyphs"].as_object().unwrap().iter().filter_map(|(k, g)| Some((k.chars().next()?, Glyph { x: g["x"].as_f64()?, w: g["w"].as_f64()?, left: g["left"].as_f64()?, adv: g["adv"].as_f64()? }))).collect();
Font { w, h, sat, size: t["size"].as_f64().unwrap_or(56.0), glyphs }
}
/// The atlas summed from its corner to a point between pixels: exact for any box, so text scales to any size cleanly.
fn sum_to(&self, x: f64, y: f64) -> f64 {
let (x, y) = (x.clamp(0.0, self.w as f64), y.clamp(0.0, self.h as f64));
let (x0, y0) = ((x.floor() as usize).min(self.w - 1), (y.floor() as usize).min(self.h - 1));
let (fx, fy) = (x - x0 as f64, y - y0 as f64);
let s = |i: usize, j: usize| self.sat[j * (self.w + 1) + i];
s(x0, y0) * (1.0 - fx) * (1.0 - fy) + s(x0 + 1, y0) * fx * (1.0 - fy) + s(x0, y0 + 1) * (1.0 - fx) * fy + s(x0 + 1, y0 + 1) * fx * fy
}
fn glyph(&self, ch: char) -> &Glyph { self.glyphs.get(&ch).or(self.glyphs.get(&'?')).expect("the atlas has a question mark") }
pub fn width(&self, px: f64, s: &str) -> f64 { s.chars().map(|c| self.glyph(c).adv).sum::<f64>() * px / self.size }
/// Draw `s` with the top of its line at `y`, `px` being the size of the type. Returns where the next letter would start.
pub fn draw(&self, img: &mut RgbImage, x: f64, y: f64, px: f64, s: &str, c: C3, alpha: f32) -> f64 {
let k = px / self.size; let mut pen = x;
for ch in s.chars() {
let g = self.glyph(ch);
let (dx0, dw, dh) = (pen + g.left * k, g.w * k, self.h as f64 * k);
for iy in y.floor() as i64..(y + dh).ceil() as i64 { for ix in dx0.floor() as i64..(dx0 + dw).ceil() as i64 {
let (sx0, sy0) = (g.x + (ix as f64 - dx0) / k, (iy as f64 - y) / k);
let (ax, bx) = (sx0.max(g.x), (sx0 + 1.0 / k).min(g.x + g.w)); let (ay, by) = (sy0.max(0.0), (sy0 + 1.0 / k).min(self.h as f64));
if bx <= ax || by <= ay { continue; }
let v = (self.sum_to(bx, by) - self.sum_to(ax, by) - self.sum_to(bx, ay) + self.sum_to(ax, ay)) * k * k / 255.0;
if v > 0.004 { blend(img, ix, iy, c, alpha * v.min(1.0) as f32); }
} }
pen += g.adv * k;
}
pen
}
pub fn line_height(&self, px: f64) -> f64 { self.h as f64 * px / self.size }
}
pub fn font(bold: bool) -> &'static Font {
static REGULAR: OnceLock<Font> = OnceLock::new(); static BOLD: OnceLock<Font> = OnceLock::new();
if bold { BOLD.get_or_init(|| Font::load(include_bytes!("../assets/label-font-bold.png"), include_str!("../assets/label-font-bold.json"))) }
else { REGULAR.get_or_init(|| Font::load(include_bytes!("../assets/label-font-regular.png"), include_str!("../assets/label-font-regular.json"))) }
}
pub fn text(img: &mut RgbImage, x: f64, y: f64, px: f64, s: &str, c: C3, bold: bool) -> f64 { font(bold).draw(img, x, y, px, s, c, 1.0) }
/// Text over a photo: a dark edge all round keeps it readable on anything.
pub fn text_halo(img: &mut RgbImage, x: f64, y: f64, px: f64, s: &str, c: C3, bold: bool) -> f64 {
let r = (px / 11.0).max(1.2);
for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7), (0.7, 0.7)] { font(bold).draw(img, x + dx * r, y + dy * r, px, s, INK, 0.9); }
font(bold).draw(img, x, y, px, s, c, 1.0)
}
pub fn text_width(px: f64, s: &str, bold: bool) -> f64 { font(bold).width(px, s) }
/// `s`, cut with three dots if it is wider than `max`.
pub fn fit(s: &str, px: f64, max: f64, bold: bool) -> String {
if text_width(px, s, bold) <= max { return s.to_string(); }
let mut out: String = s.to_string();
while !out.is_empty() && text_width(px, &format!("{out}..."), bold) > max { out.pop(); }
format!("{}...", out.trim_end())
}
/// Break `s` into lines no wider than `max`.
pub fn wrap(s: &str, px: f64, max: f64, bold: bool) -> Vec<String> {
let mut lines: Vec<String> = vec![]; let mut cur = String::new();
for word in s.split_whitespace() {
let trial = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
if text_width(px, &trial, bold) <= max || cur.is_empty() { cur = trial; } else { lines.push(std::mem::take(&mut cur)); cur = word.to_string(); }
}
if !cur.is_empty() { lines.push(cur); }
lines
}
// ---------------------------------------------------------------- shapes
#[inline]
pub fn blend(img: &mut RgbImage, x: i64, y: i64, c: C3, a: f32) {
if x < 0 || y < 0 || x >= img.width() as i64 || y >= img.height() as i64 || a <= 0.0 { return; }
let a = a.min(1.0); let p = img.get_pixel_mut(x as u32, y as u32);
for k in 0..3 { p[k] = (p[k] as f32 * (1.0 - a) + c[k] as f32 * a + 0.5) as u8; }
}
pub fn fill_rect(img: &mut RgbImage, x0: f64, y0: f64, x1: f64, y1: f64, c: C3, a: f32) {
for y in y0.round() as i64..y1.round() as i64 { for x in x0.round() as i64..x1.round() as i64 { blend(img, x, y, c, a); } }
}
pub fn frame(img: &mut RgbImage, x0: f64, y0: f64, x1: f64, y1: f64, th: f64, c: C3, a: f32) {
fill_rect(img, x0, y0, x1, y0 + th, c, a); fill_rect(img, x0, y1 - th, x1, y1, c, a); fill_rect(img, x0, y0 + th, x0 + th, y1 - th, c, a); fill_rect(img, x1 - th, y0 + th, x1, y1 - th, c, a);
}
fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
let (vx, vy) = (b.0 - a.0, b.1 - a.1); let l2 = vx * vx + vy * vy;
let t = if l2 > 0.0 { (((p.0 - a.0) * vx + (p.1 - a.1) * vy) / l2).clamp(0.0, 1.0) } else { 0.0 };
((p.0 - a.0 - t * vx).powi(2) + (p.1 - a.1 - t * vy).powi(2)).sqrt()
}
/// A line with round ends, `half` its half width.
pub fn capsule(img: &mut RgbImage, a: (f64, f64), b: (f64, f64), half: f64, c: C3, alpha: f32) {
let (x0, x1) = ((a.0.min(b.0) - half - 1.0).floor() as i64, (a.0.max(b.0) + half + 1.0).ceil() as i64);
let (y0, y1) = ((a.1.min(b.1) - half - 1.0).floor() as i64, (a.1.max(b.1) + half + 1.0).ceil() as i64);
for y in y0.max(0)..=y1.min(img.height() as i64 - 1) { for x in x0.max(0)..=x1.min(img.width() as i64 - 1) {
let v = (half - seg_dist((x as f64 + 0.5, y as f64 + 0.5), a, b) + 0.5).clamp(0.0, 1.0);
if v > 0.0 { blend(img, x, y, c, alpha * v as f32); }
} }
}
pub fn disc(img: &mut RgbImage, m: (f64, f64), r: f64, c: C3, alpha: f32) { capsule(img, m, m, r, c, alpha); }
pub fn ring(img: &mut RgbImage, m: (f64, f64), r: f64, th: f64, c: C3, alpha: f32) {
let o = r + th / 2.0 + 1.0;
for y in (m.1 - o).floor() as i64..=(m.1 + o).ceil() as i64 { for x in (m.0 - o).floor() as i64..=(m.0 + o).ceil() as i64 {
let d = ((x as f64 + 0.5 - m.0).powi(2) + (y as f64 + 0.5 - m.1).powi(2)).sqrt();
let v = (th / 2.0 - (d - r).abs() + 0.5).clamp(0.0, 1.0);
if v > 0.0 { blend(img, x, y, c, alpha * v as f32); }
} }
}
pub fn outline(img: &mut RgbImage, pts: &[(f64, f64)], half: f64, c: C3, alpha: f32) { for i in 0..pts.len() { capsule(img, pts[i], pts[(i + 1) % pts.len()], half, c, alpha); } }
/// Fill a convex shape (a turned pad).
pub fn fill_convex(img: &mut RgbImage, pts: &[(f64, f64)], c: C3, alpha: f32) {
let (x0, x1) = (pts.iter().map(|p| p.0).fold(f64::MAX, f64::min).floor() as i64, pts.iter().map(|p| p.0).fold(f64::MIN, f64::max).ceil() as i64);
let (y0, y1) = (pts.iter().map(|p| p.1).fold(f64::MAX, f64::min).floor() as i64, pts.iter().map(|p| p.1).fold(f64::MIN, f64::max).ceil() as i64);
let area: f64 = (0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); a.0 * b.1 - b.0 * a.1 }).sum();
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
for y in y0..=y1 { for x in x0..=x1 {
let p = (x as f64 + 0.5, y as f64 + 0.5);
let inside = (0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); let n = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt().max(1e-9); sign * ((b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)) / n }).fold(f64::MAX, f64::min);
let v = (inside + 0.5).clamp(0.0, 1.0);
if v > 0.0 { blend(img, x, y, c, alpha * v as f32); }
} }
}
/// Cut a line into dashes: `on` drawn, `off` skipped, measured along it.
pub fn dashes(pts: &[(f64, f64)], on: f64, off: f64) -> Vec<((f64, f64), (f64, f64))> {
let mut out = vec![]; let (mut pos, period) = (0.0, on + off);
for q in pts.windows(2) {
let l = ((q[1].0 - q[0].0).powi(2) + (q[1].1 - q[0].1).powi(2)).sqrt(); if l <= 0.0 { continue; }
let at = |t: f64| (q[0].0 + (q[1].0 - q[0].0) * t / l, q[0].1 + (q[1].1 - q[0].1) * t / l);
let mut t = 0.0;
while t < l {
// always move on: a phase a rounding error short of a boundary would otherwise stand still
let phase = (pos + t) % period;
let end = (t + (if phase < on { on - phase } else { period - phase }).max(1e-6)).min(l);
if phase < on && end - t > 1e-6 { out.push((at(t), at(end))); }
t = end;
}
pos += l;
}
out
}
// ---------------------------------------------------------------- copper in a colour and a pattern
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pattern { Solid, Bands, Diagonal, Grid, Dots, Hazard }
#[derive(Clone, Copy, Debug)]
pub struct Style { pub color: C3, pub pattern: Pattern, /// dashes for tracks, drawn and skipped, in pixels at 1080p; None is a solid line
pub dash: Option<(f64, f64)> }
/// The six rungs, in the ladder's order: hue and pattern both carry the level.
pub const RUNGS: [Style; 6] = [
Style { color: [86, 180, 233], pattern: Pattern::Solid, dash: None }, // copper: sky blue, solid
Style { color: [0, 205, 150], pattern: Pattern::Bands, dash: None }, // via: bluish green, level bands
Style { color: [245, 170, 20], pattern: Pattern::Diagonal, dash: Some((9.0, 6.0)) }, // inferred: orange, diagonal
Style { color: [220, 125, 190], pattern: Pattern::Grid, dash: Some((3.0, 5.0)) }, // group: reddish purple, grid
Style { color: [190, 190, 190], pattern: Pattern::Dots, dash: None }, // none: grey, dots
Style { color: [240, 80, 20], pattern: Pattern::Hazard, dash: Some((9.0, 6.0)) }, // conflict: vermillion, dark stripes
];
/// Eight hues to tell nets apart on one sheet (Okabe-Ito, lifted for a dark ground). The sheet also labels each net.
pub const NET_HUES: [C3; 8] = [[245, 170, 20], [86, 180, 233], [0, 205, 150], [240, 228, 66], [70, 130, 255], [240, 80, 20], [220, 125, 190], [245, 245, 245]];
/// How strongly the pattern shows at a pixel: 1 on a stroke, less between strokes, negative for the dark stripe of a hazard.
pub fn pattern_at(p: Pattern, x: i64, y: i64, unit: f64) -> f32 {
let per = (10.0 * unit).round().max(4.0) as i64; let m = |v: i64, n: i64| v.rem_euclid(n);
match p {
Pattern::Solid => 1.0,
Pattern::Bands => if m(y, per) < per * 6 / 10 { 1.0 } else { 0.38 },
Pattern::Diagonal => if m(x + y, per) < per / 2 { 1.0 } else { 0.28 },
Pattern::Grid => if m(x, per) < per * 3 / 10 || m(y, per) < per * 3 / 10 { 1.0 } else { 0.22 },
Pattern::Dots => if m(x, per) < per * 4 / 10 && m(y, per) < per * 4 / 10 { 1.0 } else { 0.3 },
Pattern::Hazard => if m(x - y, per * 3 / 2) < per * 3 / 4 { 1.0 } else { -1.0 },
}
}
/// How much of each pixel of a panel is covered by the copper being painted.
pub struct Cover { pub w: usize, pub h: usize, pub d: Vec<f32> }
impl Cover {
pub fn new(w: usize, h: usize) -> Cover { Cover { w, h, d: vec![0.0; w * h] } }
pub fn clear(&mut self) { self.d.iter_mut().for_each(|v| *v = 0.0); }
/// Add a rectangle given in panel pixels, each pixel by the share of it the rectangle covers.
pub fn add_rect(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
let (x0, y0, x1, y1) = (x0.max(0.0), y0.max(0.0), x1.min(self.w as f64), y1.min(self.h as f64));
if x1 <= x0 || y1 <= y0 { return; }
for y in y0.floor() as usize..(y1.ceil() as usize).min(self.h) {
let wy = ((y + 1) as f64).min(y1) - (y as f64).max(y0);
let row = y * self.w;
for x in x0.floor() as usize..(x1.ceil() as usize).min(self.w) { let wx = ((x + 1) as f64).min(x1) - (x as f64).max(x0); self.d[row + x] += (wx * wy) as f32; }
}
}
pub fn capsule(&mut self, a: (f64, f64), b: (f64, f64), half: f64) {
let (x0, x1) = ((a.0.min(b.0) - half - 1.0).floor().max(0.0) as usize, ((a.0.max(b.0) + half + 1.0).ceil().max(0.0) as usize).min(self.w.saturating_sub(1)));
let (y0, y1) = ((a.1.min(b.1) - half - 1.0).floor().max(0.0) as usize, ((a.1.max(b.1) + half + 1.0).ceil().max(0.0) as usize).min(self.h.saturating_sub(1)));
if a.0.max(b.0) + half < 0.0 || a.1.max(b.1) + half < 0.0 { return; }
for y in y0..=y1 { for x in x0..=x1 { let v = (half - seg_dist((x as f64 + 0.5, y as f64 + 0.5), a, b) + 0.5).clamp(0.0, 1.0) as f32; let e = &mut self.d[y * self.w + x]; if v > *e { *e = v; } } }
}
/// Paint what is covered onto the picture at (ox, oy). `patterned`: pours carry the pattern, tracks do not.
pub fn paint(&self, img: &mut RgbImage, ox: i64, oy: i64, s: &Style, alpha: f32, unit: f64, patterned: bool) {
for y in 0..self.h { for x in 0..self.w {
let v = self.d[y * self.w + x].min(1.0); if v <= 0.003 { continue; }
let (gx, gy) = (ox + x as i64, oy + y as i64);
let k = if patterned { pattern_at(s.pattern, gx, gy, unit) } else { 1.0 };
if k < 0.0 { blend(img, gx, gy, INK, alpha * v); } else { blend(img, gx, gy, s.color, alpha * v * k); }
} }
}
}
/// A square of a style, for a legend.
pub fn swatch(img: &mut RgbImage, x: f64, y: f64, size: f64, s: &Style, unit: f64) {
fill_rect(img, x, y, x + size, y + size, [44, 48, 54], 1.0);
for py in y.round() as i64..(y + size).round() as i64 { for px in x.round() as i64..(x + size).round() as i64 {
let k = pattern_at(s.pattern, px, py, unit);
if k < 0.0 { blend(img, px, py, INK, 0.92); } else { blend(img, px, py, s.color, 0.92 * k); }
} }
frame(img, x, y, x + size, y + size, (unit * 1.5).max(1.0), WHITE, 0.55);
}
pub fn save_jpeg(img: &RgbImage, path: &std::path::Path, quality: u8) -> Result<(), String> {
if let Some(d) = path.parent() { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }
let f = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut w = std::io::BufWriter::new(f);
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut w, quality).encode_image(img).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn px(c: C3) -> Rgb<u8> { Rgb(c) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_has_a_width_and_leaves_ink() {
let mut img = RgbImage::from_pixel(400, 60, px(INK));
let end = text(&mut img, 10.0, 8.0, 28.0, "GND 3V3", WHITE, true);
assert!((end - 10.0 - text_width(28.0, "GND 3V3", true)).abs() < 1e-6);
assert!(text_width(28.0, "GND 3V3", true) > 80.0 && text_width(28.0, "GND 3V3", true) < 200.0);
let lit = img.pixels().filter(|p| p[0] > 200).count();
assert!(lit > 300, "letters should leave bright pixels, got {lit}");
// nothing is drawn above the line or past the end of the text
assert!((0..400).all(|x| img.get_pixel(x, 2)[0] < 40));
assert!((end.ceil() as u32 + 3..400).all(|x| (0..60).all(|y| img.get_pixel(x, y)[0] < 40)));
// a letter the atlas does not hold falls back, it does not panic
text(&mut img, 10.0, 8.0, 20.0, "\u{2014}", WHITE, false);
}
#[test]
fn long_text_is_cut_or_wrapped_to_its_room() {
let s = "every pin meets this net's copper with no gap";
let cut = fit(s, 20.0, 200.0, false);
assert!(cut.ends_with("...") && text_width(20.0, &cut, false) <= 200.0);
assert_eq!(fit("GND", 20.0, 200.0, false), "GND");
let lines = wrap(s, 20.0, 200.0, false);
assert!(lines.len() >= 2 && lines.iter().all(|l| text_width(20.0, l, false) <= 200.0));
assert_eq!(lines.join(" "), s);
}
#[test]
fn every_level_has_its_own_hue_and_its_own_pattern() {
for a in 0..6 { for b in a + 1..6 {
assert_ne!(RUNGS[a].pattern, RUNGS[b].pattern, "levels {a} and {b} share a pattern");
let d: i32 = (0..3).map(|k| (RUNGS[a].color[k] as i32 - RUNGS[b].color[k] as i32).abs()).sum();
assert!(d > 90, "levels {a} and {b} are too close in colour");
} }
// a pattern is not flat, except the solid one: some pixels of a tile are strong and some weak
for s in &RUNGS[1..] { let v: Vec<f32> = (0..20).flat_map(|y| (0..20).map(move |x| (x, y))).map(|(x, y)| pattern_at(s.pattern, x, y, 1.0)).collect(); assert!(v.iter().any(|k| *k >= 1.0) && v.iter().any(|k| *k < 0.5)); }
}
#[test]
fn cover_counts_shares_of_pixels_and_dashes_keep_their_length() {
let mut c = Cover::new(4, 4);
c.add_rect(0.5, 0.0, 2.0, 1.0);
assert!((c.d[0] - 0.5).abs() < 1e-6 && (c.d[1] - 1.0).abs() < 1e-6 && c.d[2] == 0.0);
c.add_rect(-5.0, -5.0, 50.0, 50.0); // clipped to the panel, never out of bounds
let d = dashes(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 3.0, 2.0);
let drawn: f64 = d.iter().map(|(a, b)| ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()).sum();
assert!((drawn - 12.0).abs() < 1e-4, "{drawn}");
// lengths that do not divide evenly still end: this stood still once, at a phase just short of a dash's end
for k in 1..400 { let (on, off) = (9.0 * 1.964, 6.0 * 1.964); let l = k as f64 * 0.7313; let d = dashes(&[(0.0, 0.0), (l, 0.0), (l, l * 1.37)], on, off); assert!(d.len() <= 2 * (l * 2.37 / (on + off)) as usize + 6, "{} dashes on a line of {}", d.len(), l * 2.37); }
}
}