diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 4534275c..734d166d 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -145,7 +145,7 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result { }) } -/// The first target picture under slide point `at` and the colour of its own pixel there: the +/// The topmost target picture under slide point `at` and the colour of its own pixel there: the /// picture as stored, before corrections and recolouring, so the colour matches the pixels that /// `clear_color` is compared with. fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> { @@ -154,9 +154,12 @@ fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba let (Some(x), Some(y)) = (xy(0), xy(1)) else { return Err(bad(CMD, "`at` is [x, y] in slide units")); }; - let ids = targets(s, p)?; + let mut ids = targets(s, p)?; let st = s.doc()?; let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + // Same order as the hit-test: later in the shape list is in front, and a group's children sit + // where the group is. The selection order says nothing about what is on top. + ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id))); 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 }; @@ -191,6 +194,23 @@ fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba Err(bad(CMD, "the point is not on a selected picture")) } +/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger +/// path is drawn later, so in front. +fn stack_path(shapes: &[Shape], id: ShapeId) -> Option> { + for (i, sh) in shapes.iter().enumerate() { + if sh.id == id { + return Some(vec![i]); + } + if let ShapeKind::Group { children, .. } = &sh.kind + && let Some(mut rest) = stack_path(children, id) + { + rest.insert(0, i); + return Some(rest); + } + } + None +} + /// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide /// space. fn placed<'a>( diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9a367cae..0a703232 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2465,6 +2465,23 @@ fn transparent_color_at_a_point_finds_a_grouped_picture_without_its_own_box() { assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(180, 0, 0))); } +#[test] +fn transparent_color_at_a_point_takes_the_topmost_selected_picture() { + let mut s = session(); + // `under` is inserted first (drawn below), `over` after it (drawn on top); they overlap in x 150..300. + let under = ramp_picture(&mut s, 10, [100, 100, 200, 100]); + let over = ramp_picture(&mut s, 10, [150, 100, 200, 100]); + // Selected bottom first: selection order is the opposite of the stacking order. + s.execute("edit.select", &json!({"ids": [under, over]})).unwrap(); + // x = 250 is column 7 of `under` (140) and column 5 of `over` (100): the click is on `over`. + s.execute("picture.transparentColor", &json!({"at": [250, 150]})).unwrap(); + assert_eq!(clear_color_of(&s, &over), Some(deckcraft_color::Rgba::rgb(100, 0, 0))); + assert_eq!(clear_color_of(&s, &under), None); + // Where `over` is not, the picture below is still found. + s.execute("picture.transparentColor", &json!({"at": [120, 150]})).unwrap(); + assert_eq!(clear_color_of(&s, &under), Some(deckcraft_color::Rgba::rgb(20, 0, 0))); +} + #[test] fn select_all_slides_scope() { let mut s = session();