← Commit history

Merge PR #220: Optimized GLB: crop the silk capture to the board and key out the backdrop (#113)

John Lauer ·429dc09aea ·1mo ago ·parent 6372b5f
1 file changed +58−1
rust/bridge/src/services.rs+58−1
@@ -49,10 +49,46 @@ fn multipart(files: &[(&str, String, Vec<u8>, &str)]) -> (Vec<u8>, String) {     (out, boundary) } +/// The add-in's silk capture is a square 2048 px render with margin around the board, and the board body stays+/// visible as a backdrop (Fusion draws nothing on a canvas without it). The optimizer maps the WHOLE image onto+/// the board, so an unprepared capture bakes in offset, shrunken labels on an opaque board-coloured sheet+/// (fusion-bridge#113). Crop to the board (the opaque part of the transparent-background capture) and keep only+/// the near-white silk strokes, transparent everywhere else. Returns None when the image cannot be read.+pub(crate) fn clean_silk_png(bytes: &[u8]) -> Option<Vec<u8>> {+    let mut dec = png::Decoder::new(bytes);+    dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);+    let mut rd = dec.read_info().ok()?;+    let mut buf = vec![0u8; rd.output_buffer_size()];+    let info = rd.next_frame(&mut buf).ok()?;+    let (w, h) = (info.width as usize, info.height as usize);+    let ch = match info.color_type { png::ColorType::Rgba => 4, png::ColorType::Rgb => 3, png::ColorType::GrayscaleAlpha => 2, png::ColorType::Grayscale => 1, _ => return None };+    let px = |x: usize, y: usize| -> (f32, f32, f32, f32) {+        let i = (y * w + x) * ch;+        match ch { 4 => (buf[i] as f32, buf[i + 1] as f32, buf[i + 2] as f32, buf[i + 3] as f32), 3 => (buf[i] as f32, buf[i + 1] as f32, buf[i + 2] as f32, 255.0),+                   2 => (buf[i] as f32, buf[i] as f32, buf[i] as f32, buf[i + 1] as f32), _ => (buf[i] as f32, buf[i] as f32, buf[i] as f32, 255.0) }+    };+    // the board: bounding box of the opaque pixels (an opaque capture has no margin to crop, so it stays whole)+    let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);+    for y in 0..h { for x in 0..w { if px(x, y).3 > 8.0 { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); } } }+    if x0 > x1 || y0 > y1 { return None; }+    let (cw, chh) = (x1 - x0 + 1, y1 - y0 + 1);+    let mut out = Vec::with_capacity(cw * chh * 4);+    for y in y0..=y1 { for x in x0..=x1 {+        let (r, g, b, a) = px(x, y);+        let lum = 0.299 * r + 0.587 * g + 0.114 * b;               // silk is near white; board fill, pads and holes are dark+        let alpha = ((lum - 150.0) / 70.0).clamp(0.0, 1.0) * (a / 255.0);+        out.extend_from_slice(&[245, 245, 245, (alpha * 255.0).round() as u8]);+    } }+    let mut png_out = Vec::new();+    { let mut enc = png::Encoder::new(&mut png_out, cw as u32, chh as u32); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight);+      let mut wr = enc.write_header().ok()?; wr.write_image_data(&out).ok()?; }+    Some(png_out)+}+ fn glb_submit(step_path: &str, silk_top: Option<&str>, silk_bottom: Option<&str>, pin: &str, job_name: &str) -> Value {     let Ok(step) = std::fs::read(step_path) else { return json!({"ok": false, "error": format!("could not read STEP: {step_path}")}) };     let mut files: Vec<(&str, String, Vec<u8>, &str)> = vec![("step", std::path::Path::new(step_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), step, "application/step")];-    for (field, p) in [("silk_top", silk_top), ("silk_bottom", silk_bottom)] { if let Some(p) = p { if let Ok(b) = std::fs::read(p) { files.push((field, std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), b, "image/png")); } } }+    for (field, p) in [("silk_top", silk_top), ("silk_bottom", silk_bottom)] { if let Some(p) = p { if let Ok(raw) = std::fs::read(p) { let b = clean_silk_png(&raw).unwrap_or(raw); files.push((field, std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), b, "image/png")); } } }     let (body, boundary) = multipart(&files);     let url = format!("{}/convert?molecule=true&pin={}", step2glb_url(), fusion_core::aps::urlencode(pin));     let resp = ureq::post(&url).timeout(Duration::from_secs(90)).set("Content-Type", &format!("multipart/form-data; boundary={boundary}")).set("X-Client", "fusion-bridge/adom").set("X-Job-Name", job_name).set("User-Agent", &service_ua()).send_bytes(&body);@@ -290,3 +326,24 @@ pub fn set_design_rules(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V     }     res }++#[cfg(test)]+mod silk_tests {+    use super::clean_silk_png;+    fn enc(w: u32, h: u32, px: &[u8]) -> Vec<u8> { let mut v = Vec::new(); { let mut e = png::Encoder::new(&mut v, w, h); e.set_color(png::ColorType::Rgba); e.set_depth(png::BitDepth::Eight); e.write_header().unwrap().write_image_data(px).unwrap(); } v }+    fn dec(b: &[u8]) -> (u32, u32, Vec<u8>) { let mut r = png::Decoder::new(b).read_info().unwrap(); let mut buf = vec![0; r.output_buffer_size()]; let i = r.next_frame(&mut buf).unwrap(); (i.width, i.height, buf) }+    #[test]+    fn crops_to_board_and_keeps_only_silk() {+        // 10x10 transparent canvas, a 6x4 opaque brown board at (2,3), one white silk pixel at (4,5)+        let mut px = vec![0u8; 10 * 10 * 4];+        for y in 3..7 { for x in 2..8 { let i = (y * 10 + x) * 4; px[i..i + 4].copy_from_slice(&[110, 100, 65, 255]); } }+        let i = (5 * 10 + 4) * 4; px[i..i + 4].copy_from_slice(&[250, 250, 250, 255]);+        let (w, h, out) = dec(&clean_silk_png(&enc(10, 10, &px)).unwrap());+        assert_eq!((w, h), (6, 4));+        let a = |x: usize, y: usize| out[(y * 6 + x) * 4 + 3];+        assert_eq!(a(2, 2), 255, "the silk pixel stays opaque");+        assert_eq!(a(0, 0), 0, "the board fill turns transparent");+    }+    #[test]+    fn empty_capture_is_left_alone() { assert!(clean_silk_png(&enc(4, 4, &[0u8; 64])).is_none()); }+}