main
Caleb Publish 0.5.0 1984b40
//! The nets as a page: a net list beside the board, a click lights a net on both faces, and a switch between
//! "by confidence" and "by net". `nets draw` writes it to nets/view.html and records it in the flow window as a
//! numbered view, like every other stage's view.
//!
//! The page carries everything it needs but the two face photos: each face's copper as a small map (which net and
//! which rung at every pixel, 20 px/mm, as a PNG inside the page) and the nets with their levels. So a recorded view
//! still shows the nets as they were when the run moves on.
use crate::netconf::{self, rank, LADDER};
use crate::netdraw::Scene;
use crate::netpaint::{NET_HUES, RUNGS};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;

const PAGE: &str = include_str!("../assets/nets-view.html");
/// Pixels per mm of the copper maps: half the working resolution, enough for a 0.1 mm track to show.
const MAP_RES: f64 = 20.0;
const ORIGIN: f64 = crate::board::ORIGIN;

fn base64(data: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    for c in data.chunks(3) {
        let v = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
        out.push(T[(v >> 18) as usize & 63] as char); out.push(T[(v >> 12) as usize & 63] as char);
        out.push(if c.len() > 1 { T[(v >> 6) as usize & 63] as char } else { '=' }); out.push(if c.len() > 2 { T[v as usize & 63] as char } else { '=' });
    }
    out
}

/// One face's copper as pixels: red and green hold the net's number plus one, blue the rung plus one, black is no copper.
pub fn copper_map(sc: &Scene, side: usize) -> image::RgbImage {
    let (w, h) = ((sc.wmm * MAP_RES).round() as u32, (sc.hmm * MAP_RES).round() as u32);
    let mut img = image::RgbImage::new(w.max(1), h.max(1));
    let k = crate::board::RES / MAP_RES;
    let px = |net: usize, rung: usize| image::Rgb([((net + 1) & 255) as u8, ((net + 1) >> 8) as u8, rung as u8 + 1]);
    let mut dot = |img: &mut image::RgbImage, x: f64, y: f64, r: f64, c: image::Rgb<u8>| {
        let (cx, cy) = ((x - ORIGIN) * MAP_RES, (y - ORIGIN) * MAP_RES);
        for v in (cy - r).floor() as i64..=(cy + r).ceil() as i64 { for u in (cx - r).floor() as i64..=(cx + r).ceil() as i64 {
            if u >= 0 && v >= 0 && (u as u32) < w && (v as u32) < h && (u as f64 + 0.5 - cx).powi(2) + (v as f64 + 0.5 - cy).powi(2) <= r * r { img.put_pixel(u as u32, v as u32, c); }
        } }
    };
    for z in sc.zones.iter().filter(|z| z.side == side) { let c = px(z.net, z.rung); for (y, x0, x1) in &z.runs {
        let v = (*y as f64 / k) as u32; if v >= h { continue; }
        for u in (*x0 as f64 / k) as u32..=((*x1 - 1) as f64 / k) as u32 { if u < w { img.put_pixel(u, v, c); } }
    } }
    for t in sc.tracks.iter().filter(|t| t.side == side) { let c = px(t.net, t.rung); let r = (t.width * MAP_RES / 2.0).max(1.1);
        for q in t.pts.windows(2) { let n = (crate::geom::dist(q[0], q[1]) * MAP_RES * 2.0).ceil().max(1.0) as usize; for i in 0..=n { let a = i as f64 / n as f64; dot(&mut img, q[0].0 + (q[1].0 - q[0].0) * a, q[0].1 + (q[1].1 - q[0].1) * a, r, c); } }
    }
    for v in &sc.vias { dot(&mut img, v.x, v.y, (v.d * MAP_RES / 2.0).max(2.2), px(v.net, v.rung)); }
    img
}

/// Write the page. `sections`: the rows `nets draw` built for sections.json.
pub fn write(sc: &Scene, conf: &Value, sections: &[Value], path: &Path) -> Result<(), String> {
    let mut maps = serde_json::Map::new();
    for (k, side) in ["top", "bottom"].iter().enumerate() {
        if sc.faces[k].is_none() { continue; }
        let m = copper_map(sc, k); let mut png: Vec<u8> = vec![];
        image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut png), m.as_raw(), m.width(), m.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
        maps.insert(side.to_string(), json!(format!("data:image/png;base64,{}", base64(&png))));
    }
    // every number a map pixel can hold: a named net, or the class of copper with no net
    let mut ids = vec![json!({"u": 4}); sc.net_ix.len()];
    for (name, k) in &sc.net_ix { ids[*k] = match netconf::unnamed_class(name) { Some(c) => json!({"u": rank(c)}), None => json!({"n": name}) }; }
    let rows: Vec<&Value> = conf["nets"].as_array().into_iter().flatten().collect();
    let section_of: HashMap<&str, usize> = sections.iter().enumerate().flat_map(|(i, s)| s["nets"].as_array().into_iter().flatten().filter_map(move |n| n.as_str().map(|n| (n, i)))).collect();
    let mut pins = serde_json::Map::new();
    let nets: Vec<Value> = rows.iter().map(|r| {
        let name = r["net"].as_str().unwrap_or("");
        let mut list = vec![];
        for (p, seen) in r["seenPins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).map(|p| (p, 1)).chain(r["inferredMembers"].as_array().into_iter().flatten().filter_map(|p| p["pin"].as_str()).map(|p| (p, 0))) {
            if let Some(q) = sc.pins.get(p) { list.push(json!([(q.x * 100.0).round() / 100.0, (q.y * 100.0).round() / 100.0, q.sides[0] as u8 + 2 * q.sides[1] as u8, seen, p])); }
        }
        pins.insert(name.to_string(), json!(list));
        let w = &r["weakestLink"];
        json!({"name": name, "level": r["level"], "pins": r["pins"], "seen": r["pinsSeen"], "single": r["singlePin"], "weakest": w["what"], "at": w["at"], "side": w["side"], "file": r["file"],
            "section": section_of.get(name), "disputes": r["disputes"].as_array().map(|a| a.len()).unwrap_or(0)})
    }).collect();
    let sm = &conf["summary"];
    let data = json!({"name": sc.name, "wmm": sc.wmm, "hmm": sc.hmm, "res": MAP_RES, "ladder": LADDER, "rungColors": RUNGS.iter().map(|s| s.color).collect::<Vec<_>>(), "netHues": NET_HUES,
        "ids": ids, "nets": nets, "pins": pins, "sections": sections.iter().map(|s| json!({"title": s["title"], "kind": s["kind"], "nets": s["nets"]})).collect::<Vec<_>>(),
        "unnamed": {"islands": sm["unnamedIslands"], "groups": sm["unnamed"]["group"]["names"], "conflicts": sm["unnamed"]["conflict"]["names"]}});
    // "</" inside a script would end it early
    let safe = |v: String| v.replace("</", "<\\/");
    let page = PAGE.replace("/*DATA*/null/*END*/", &safe(data.to_string())).replace("/*MAPS*/{}/*END*/", &safe(Value::Object(maps).to_string()));
    std::fs::write(path, page).map_err(|e| format!("{}: {e}", path.display()))
}

/// Record nets/view.html as a numbered view of `step`, the way `views::record` records the board. An unchanged page
/// reuses the file recorded before. Returns the hints to print.
pub fn record(flow: &Path, step: &str, thread: &str) -> Vec<String> {
    if crate::views::disabled() { return vec![]; }
    let src = flow.join("nets/view.html");
    if !src.is_file() { return vec![]; }
    let path = flow.join("views.json");
    let mut doc = crate::items::read_json(&path).unwrap_or(json!({"entries": []}));
    if !doc["entries"].is_array() { doc["entries"] = json!([]); }
    let n = doc["entries"].as_array().map(|a| a.len()).unwrap_or(0) + 1;
    let sha = crate::sha256(&src);
    let before = doc["entries"].as_array().into_iter().flatten().rev().flat_map(|e| e["views"].as_array().cloned().unwrap_or_default()).find(|v| v["kind"] == json!("nets") && v["sha"] == json!(sha)).and_then(|v| v["file"].as_str().map(str::to_string)).filter(|f| flow.join(f).is_file());
    let file = match before { Some(f) => f, None => {
        let f = format!("views/{n:03}-{step}-nets.html");
        if let Err(e) = std::fs::create_dir_all(flow.join("views")).and_then(|_| std::fs::copy(&src, flow.join(&f)).map(|_| ())) { return vec![format!("flow window: the nets view was not recorded: {e}")]; }
        f
    } };
    doc["entries"].as_array_mut().unwrap().push(json!({"n": n, "at": crate::ledger::now(), "step": step, "reason": "nets drawn", "aiThread": thread, "views": [{"kind": "nets", "file": file, "source": "nets/view.html", "sha": sha}]}));
    if let Err(e) = std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap() + "\n") { return vec![format!("flow window: views.json not written: {e}")]; }
    vec![format!("flow window: view {n} recorded for {step} (nets): a net list beside the board, by confidence or by net")]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn base64_pads_the_last_group() {
        assert_eq!(base64(b""), ""); assert_eq!(base64(b"f"), "Zg=="); assert_eq!(base64(b"fo"), "Zm8="); assert_eq!(base64(b"foo"), "Zm9v"); assert_eq!(base64(b"foobar"), "Zm9vYmFy");
    }

    #[test]
    fn the_page_has_a_place_for_its_data_and_its_maps() {
        assert_eq!(PAGE.matches("/*DATA*/null/*END*/").count(), 1); assert_eq!(PAGE.matches("/*MAPS*/{}/*END*/").count(), 1);
    }

    #[test]
    fn a_nets_view_is_numbered_after_what_is_already_recorded_and_an_unchanged_page_is_not_copied_twice() {
        let d = std::env::temp_dir().join(format!("netview-test-{}", std::process::id()));
        std::fs::create_dir_all(d.join("nets")).unwrap();
        std::fs::write(d.join("nets/view.html"), "<html>one</html>").unwrap();
        std::fs::write(d.join("views.json"), r#"{"entries": [{"n": 1, "step": "parts", "views": [{"kind": "photos"}]}]}"#).unwrap();
        let h = record(&d, "report", "t");
        assert!(h[0].contains("view 2 recorded for report (nets)"), "{h:?}");
        assert!(d.join("views/002-report-nets.html").is_file());
        record(&d, "report", "t");
        let doc = crate::items::read_json(&d.join("views.json")).unwrap();
        let e = doc["entries"].as_array().unwrap();
        assert_eq!(e.len(), 3); assert_eq!(e[2]["views"][0]["file"], "views/002-report-nets.html", "the same page is not copied again");
        std::fs::write(d.join("nets/view.html"), "<html>two</html>").unwrap();
        record(&d, "report", "t");
        assert!(d.join("views/004-report-nets.html").is_file());
        std::fs::remove_dir_all(&d).ok();
    }
}