From b7ea777be7fb193c5aa001085f86e46dd5bec842 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 19:16:09 +0200 Subject: [PATCH] Set Transparent Color: map the click through the picture's 3-D camera and find grouped pictures that keep no box of their own --- crates/engine/src/cmd/recolor.rs | 6 ++- crates/engine/src/tests.rs | 74 ++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 20 +++++---- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index a1a83166..4534275c 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -160,7 +160,9 @@ fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba for id in ids { let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue }; let ShapeKind::Picture { fill } = &sh.kind else { continue }; - let local = to_slide.inverse() * Point::new(x, y); + // The camera turns the picture where it is drawn, so the click is mapped back through it too. + let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY); + let local = (to_slide * turn).inverse() * Point::new(x, y); if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) { continue; } @@ -203,7 +205,7 @@ fn placed<'a>( return None; } for sh in shapes { - let Some(x) = (if depth == 0 { Some(xfrm_of(doc, sel, sh)) } else { sh.xfrm }) else { continue }; + let x = xfrm_of(doc, sel, sh); if sh.id == id { return Some((sh, x, to_slide * x.affine())); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0d043fc5..9a367cae 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2391,6 +2391,80 @@ fn transparent_color_at_a_point_takes_the_pictures_own_pixel_there() { s.execute("picture.transparentColor", &json!({"ids": [logo], "at": [450, 500]})).unwrap(); } +/// A picture of `n` one-pixel columns whose red channel is 20 * column, inserted at `rect`. +fn ramp_picture(s: &mut Session, n: u32, rect: [u32; 4]) -> serde_json::Value { + let img = image::RgbaImage::from_fn(n, 1, |x, _| image::Rgba([(20 * x) as u8, 0, 0, 255])); + let mut png = vec![]; + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "r.png", "rect": rect})).unwrap()["id"].clone() +} + +fn clear_color_of(s: &Session, id: &serde_json::Value) -> Option { + fn find(shapes: &[deckcraft_model::Shape], id: &serde_json::Value) -> Option { + shapes.iter().find_map(|sh| { + if json!(sh.id) == *id { + return Some(sh.clone()); + } + match &sh.kind { + deckcraft_model::ShapeKind::Group { children, .. } => find(children, id), + _ => None, + } + }) + } + match find(s.doc().unwrap().shapes(), id).unwrap().kind { + deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clear_color, + _ => panic!("not a picture"), + } +} + +#[test] +fn transparent_color_at_a_point_follows_the_pictures_3d_camera() { + let mut s = session(); + let id = ramp_picture(&mut s, 10, [100, 100, 200, 100]); + // Turned 60 degrees about the vertical axis the picture is drawn about half as wide, centred + // where it was: 149..251 instead of 100..300. + s.execute("shape.effects", &json!({"ids": [id], "threeD": {"camera": "orthographicFront", "rotX": 60}})).unwrap(); + // 175 is a quarter of the way across the drawn picture: column 2, not column 3 (the 75/200 + // of the unturned box). + s.execute("picture.transparentColor", &json!({"ids": [id], "at": [175, 150]})).unwrap(); + assert_eq!(clear_color_of(&s, &id), Some(deckcraft_color::Rgba::rgb(40, 0, 0))); + // Inside the unturned box but outside the drawn picture: nothing is under the pointer. + assert!(s.execute("picture.transparentColor", &json!({"ids": [id], "at": [120, 150]})).is_err()); +} + +#[test] +fn transparent_color_at_a_point_finds_a_grouped_picture_without_its_own_box() { + let mut s = session(); + let pic = ramp_picture(&mut s, 10, [100, 100, 200, 100]); + let other = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].clone(); + s.execute("edit.select", &json!({"ids": [pic, other]})).unwrap(); + s.execute("arrange.group", &json!({})).unwrap(); + // A grouped picture that keeps no box of its own is drawn where the default box puts it. + let sz = s.doc().unwrap().doc.slide_size; + s.edit(|doc, sel| { + fn clear(shapes: &mut [deckcraft_model::Shape], id: &serde_json::Value) { + for sh in shapes { + if json!(sh.id) == *id { + sh.xfrm = None; + } + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { + clear(children, id); + } + } + } + clear(crate::shapes_mut(doc, sel).unwrap(), &pic); + Ok(()) + }) + .unwrap(); + // Left tenth of the default box (10%, 10%, 80% x 20% of the slide): column 0. + let at = [sz.width * 0.1 + 1.0, sz.height * 0.2]; + s.execute("picture.transparentColor", &json!({"ids": [pic], "at": at})).unwrap(); + assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(0, 0, 0))); + let at = [sz.width * 0.9 - 1.0, sz.height * 0.2]; + s.execute("picture.transparentColor", &json!({"ids": [pic], "at": at})).unwrap(); + assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(180, 0, 0))); +} + #[test] fn select_all_slides_scope() { let mut s = session(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cf124a9f..27d0be1e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -600,6 +600,16 @@ pub fn parse_path(d: &str) -> BezPath { bp } +/// The transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine +/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts. +pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option { + if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { + return None; + } + let (e, _) = resolve::effects(rctx, s); + e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h)) +} + /// The rectangle a picture's image occupies given its crop (fractions; negative = padding). pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect { let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(-10.0, 0.99) } else { 0.0 }); @@ -873,15 +883,7 @@ impl Renderer { * Affine::translate(-c.to_vec2()); m = parent * Affine::translate(st.offset) * x.affine() * around; } - // A 3-D camera turns the shape (approximated by the affine transform that fits it best). - if !matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) - && let Some(a) = resolve::effects(rctx, s) - .0 - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, x.w, x.h)) - { + if let Some(a) = camera_turn(rctx, s, x.w, x.h) { m *= a; } let layered = st.opacity < 0.999 || st.clip.is_some();