From 16a73dc02aebaf331cf334d607a864795d6f62d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:16:36 -0400 Subject: [PATCH] =?UTF-8?q?M12.7:=20Object=20=E2=86=92=20Create=20Object?= =?UTF-8?q?=20Mosaic=E2=80=A6=20(trace::mosaic=20tile=20averages;=20column?= =?UTF-8?q?s/rows,=20new=20size,=20spacing,=20gray,=20delete=20raster;=20f?= =?UTF-8?q?ollows=20the=20image=20transform)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/menucmds.rs | 84 +++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 43 ++++++++++++++++ crates/trace/src/lib.rs | 2 + crates/trace/src/mosaic.rs | 56 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 6 ++- 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 crates/trace/src/mosaic.rs diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 8ab2e690..5c0c5f64 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -101,6 +101,15 @@ pub fn specs() -> Vec { has_selection, rasterize ), + cmd!( + "object.createObjectMosaic", + "Create Object Mosaic…", + ["Object"], + None, + "{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}", + has_image, + object_mosaic + ), cmd!( "object.cropImage", "Crop Image", @@ -440,6 +449,81 @@ fn has_image(s: &Session) -> std::result::Result<(), String> { } } +fn object_mosaic(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.createObjectMosaic"; + let st = s.doc()?; + let id = st + .selection + .objects + .iter() + .copied() + .find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) + .ok_or_else(|| bad(C, "select an image"))?; + let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let NodeKind::Image(im) = &node.kind else { unreachable!() }; + let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?; + let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?; + let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32; + let gray = bool_or(p, "gray", false); + let colors = vectorcraft_trace::mosaic(&raster, cols, rows); + // Tiles in image space, placed by the image transform (scaled to a new size if asked). + let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; + let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1)); + let resize = Affine::translate(b.origin().to_vec2()) + * Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9)) + * Affine::translate(-b.origin().to_vec2()); + let xf = resize * im.xf; + let (tw, th) = (im.width as f64 / cols as f64, im.height as f64 / rows as f64); + // Spacing is in points; convert to image pixels along each axis. + let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9); + let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9); + let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9)); + let delete = bool_or(p, "deleteRaster", false); + let (gid, n) = s.edit("Create Object Mosaic", |d, sel| { + let mut tiles = vec![]; + for r in 0..rows { + for c in 0..cols { + let [cr, cg, cb, ca] = colors[(r * cols + c) as usize]; + if ca == 0 { + continue; + } + let x0 = c as f64 * tw + gx / 2.0; + let y0 = r as f64 * th + gy / 2.0; + let quad = [Point::new(x0, y0), Point::new(x0 + tw - gx, y0), Point::new(x0 + tw - gx, y0 + th - gy), Point::new(x0, y0 + th - gy)] + .map(|q| xf * q); + let color = if gray { + let l = (0.299 * cr as f32 + 0.587 * cg as f32 + 0.114 * cb as f32) / 255.0; + Color::rgb(l, l, l) + } else { + Color::rgb(cr as f32 / 255.0, cg as f32 / 255.0, cb as f32 / 255.0) + }; + let mut tile = Node::path(d.alloc_id(), polygon_path(&quad), Appearance::basic(Paint::solid(color), Paint::None, 0.0)); + if ca < 255 { + tile.opacity = ca as f32 / 255.0; + } + tiles.push(Arc::new(tile)); + } + } + let n = tiles.len(); + let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let gid = d.alloc_id(); + let mut g = Node::group(gid, tiles); + g.name = Some("Object Mosaic".into()); + d.insert(par, idx + 1, g)?; + if delete { + d.remove(id)?; + } + sel.set([gid]); + Ok((gid, n)) + })?; + Ok(json!({ "id": gid.0, "tiles": n })) +} + +fn polygon_path(pts: &[Point]) -> PathData { + PathData::single(vectorcraft_geom::SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true)) +} + fn crop_image(s: &mut Session, p: &Value) -> Result { const C: &str = "object.cropImage"; let st = s.doc()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 81db4186..e568fcfe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -396,3 +396,46 @@ fn stale_current_layer_id_never_targets_a_reused_id() { s.execute("layer.delete", &json!({})).ok(); assert!(s.doc().unwrap().doc.node(c).is_some()); } + +#[test] +fn object_mosaic_tiles_follow_the_image_colours() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [a.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [a.0]})).unwrap(); + let b = rect(&mut s, 50.0, 0.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [b.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [b.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "spacingX": 4, "deleteRaster": true})).unwrap(); + assert_eq!(r["tiles"], json!(2)); + let st = s.doc().unwrap(); + let g = st.doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap(); + let tiles = g.children().unwrap(); + let fill = |n: &vectorcraft_doc::Node| n.appearance.fill_paint(); + assert_eq!(fill(&tiles[0]), vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(fill(&tiles[1]), vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + // 4 pt spacing: each 50 pt tile shrinks to 46 pt; the image is gone. + assert!((tiles[0].geometric_bounds().unwrap().width() - 46.0).abs() < 1e-6); + assert!(!st.doc.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)))); +} + +#[test] +fn snap_to_pixel_rounds_drawing_and_moves() { + let mut s = session(); + let v = ViewInfo { snap_to_pixel: true, smart_guides: false, ..Default::default() }; + s.select_tool("rectangle", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 10.3, 10.6), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.4, 40.2), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.4, 40.2), v).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 11.0, 60.0, 40.0)); + s.select_tool("selection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 37.3, 34.8), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 37.3, 34.8), v).unwrap(); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0), (17.0, 16.0)); +} diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index c28c1db9..7eabf0fe 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -24,12 +24,14 @@ mod contour; mod fit; +mod mosaic; mod quantize; use serde::{Deserialize, Serialize}; use vectorcraft_geom::PathData; pub use contour::{Component, Loop, trace_mask}; +pub use mosaic::mosaic; pub use quantize::{Quantized, TRANSPARENT, denoise, quantize}; /// Errors decoding a raster. diff --git a/crates/trace/src/mosaic.rs b/crates/trace/src/mosaic.rs new file mode 100644 index 00000000..ac2ae305 --- /dev/null +++ b/crates/trace/src/mosaic.rs @@ -0,0 +1,56 @@ +//! Object → Create Object Mosaic: average colours of a grid of tiles over a raster. + +use crate::Raster; + +/// Average colour (straight RGBA) of each of `cols` × `rows` tiles covering `img`, row by row. +/// Transparent pixels count by their alpha, so a tile over transparency fades out. +pub fn mosaic(img: &Raster, cols: u32, rows: u32) -> Vec<[u8; 4]> { + let (cols, rows) = (cols.max(1), rows.max(1)); + let mut out = Vec::with_capacity((cols * rows) as usize); + for r in 0..rows { + let y0 = (r as u64 * img.height as u64 / rows as u64) as u32; + let y1 = (((r + 1) as u64 * img.height as u64 / rows as u64) as u32).max(y0 + 1).min(img.height); + for c in 0..cols { + let x0 = (c as u64 * img.width as u64 / cols as u64) as u32; + let x1 = (((c + 1) as u64 * img.width as u64 / cols as u64) as u32).max(x0 + 1).min(img.width); + let (mut sr, mut sg, mut sb, mut sa, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); + for y in y0..y1.max(y0 + 1).min(img.height) { + for x in x0..x1.max(x0 + 1).min(img.width) { + let [pr, pg, pb, pa] = img.pixel(x, y); + let a = pa as u64; + sr += pr as u64 * a; + sg += pg as u64 * a; + sb += pb as u64 * a; + sa += a; + n += 1; + } + } + out.push(match (sr.checked_div(sa), sg.checked_div(sa), sb.checked_div(sa)) { + (Some(r), Some(g), Some(b)) => [r as u8, g as u8, b as u8, (sa / n.max(1)) as u8], + _ => [0, 0, 0, 0], + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn averages_each_tile() { + // Left half red, right half blue; a 2 × 1 mosaic gives one tile of each. + let img = Raster::from_fn(10, 4, |x, _| if x < 5 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); + assert_eq!(mosaic(&img, 2, 1), vec![[255, 0, 0, 255], [0, 0, 255, 255]]); + // One tile: the average, purple. + let m = mosaic(&img, 1, 1); + assert_eq!(m[0][3], 255); + assert!((m[0][0] as i32 - 127).abs() <= 1 && (m[0][2] as i32 - 127).abs() <= 1); + // More tiles than pixels still works. + assert_eq!(mosaic(&img, 20, 8).len(), 160); + // Transparency. + let clear = Raster::from_fn(2, 2, |_, _| [9, 9, 9, 0]); + assert_eq!(mosaic(&clear, 1, 1), vec![[0, 0, 0, 0]]); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 803ddd72..e01a56f2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -749,7 +749,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Crop Image", "object.cropImage"), cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})), cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), - todo("Create Object Mosaic…"), + cp( + "Create Object Mosaic…", + "object.createObjectMosaic", + json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), + ), c("Create Trim Marks", "object.createTrimMarks"), todo("Flatten Transparency…"), Sep,