From 796e9d713d11b4314cffeb7f893c408112e5d0bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 18:47:51 +0000 Subject: [PATCH] Integration: the Eyedropper matches the clicked frame where no image pixel shows; sampling skips clipped-out pixels and hidden states (#572, #561) color.sample takes `matchFrom`: with no opaque image pixel under the point (an empty part of an image frame, a transparent pixel), it applies that object's attributes as object.matchAttributes does, in the same command; the tool passes the object under the pointer. sample_item and the tool walk shown_children(), so only a multi-state object's active state is sampled, and sample_item skips points outside the graphic's clipping path. object.matchAttributes's body is a function both commands share. --- crates/engine/src/cmd/object.rs | 43 +++++++++++++------------ crates/engine/src/cmd/style.rs | 57 +++++++++++++++++++++++++++++++-- crates/tools/src/tests.rs | 4 +++ crates/tools/src/xform.rs | 16 ++++++--- 4 files changed, 93 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4a9f6a0ac..e460fe9b6 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -105,26 +105,7 @@ pub fn specs() -> Vec { None, "{from: id, ids?} — copy fill, stroke, corners, opacity, effects, wrap", has_selection, - |s, p| { - let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?; - let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?; - set_flag( - s, - p, - move |i| { - if i.id == src.id { - return; - } - i.fill = src.fill.clone(); - i.stroke = src.stroke.clone(); - i.corners = src.corners; - i.opacity = src.opacity; - i.blend = src.blend; - i.effects = src.effects.clone(); - }, - false, - ) - } + match_attributes ), cmd!("transform.flip", "Flip", ["Object", "Transform"], None, "{axis: horizontal|vertical, ids?}", has_selection, |s, p| { let h = str_param(p, "axis") != Some("vertical"); @@ -1876,6 +1857,28 @@ fn corner_options(s: &mut Session, p: &Value) -> Result { set_flag(s, p, move |i| i.corners = opts, false) } +/// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance. +pub(super) fn match_attributes(s: &mut Session, p: &Value) -> Result { + let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?; + let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?; + set_flag( + s, + p, + move |i| { + if i.id == src.id { + return; + } + i.fill = src.fill.clone(); + i.stroke = src.stroke.clone(); + i.corners = src.corners; + i.opacity = src.opacity; + i.blend = src.blend; + i.effects = src.effects.clone(); + }, + false, + ) +} + fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result { let ids = targets(s, p)?; s.edit(|d, sel| { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 62fefd953..3ad5b0e31 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -190,7 +190,7 @@ pub fn specs() -> Vec { "Sample Color (Eyedropper)", [], None, - "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects", + "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?, matchFrom?: id} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects. With no opaque image pixel there, `matchFrom` gives the object whose attributes apply instead (object.matchAttributes)", |s| super::has_text(s).or_else(|_| super::has_selection(s)), sample_color ), @@ -1141,7 +1141,18 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; let sr = super::spread_param(p, "spread"); let st = s.doc()?; - let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; + let Some((from, color)) = image_color_at(&st.doc, sr, at) else { + // No opaque image pixel there (an empty part of an image frame, a transparent pixel): the + // Eyedropper applies the clicked object's attributes instead. + return match p.get("matchFrom") { + Some(from) => { + let mut params = p.clone(); + params["from"] = from.clone(); + super::object::match_attributes(s, ¶ms) + } + None => Err(bad(ID, "no placed image under the point")), + }; + }; let hex = color.to_hex(); let mut params = p.clone(); params["color"] = json!(hex); @@ -1182,12 +1193,23 @@ fn sample_item( } let xf = outer * it.xf; match &it.content { - Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)), + // Only the active state of a multi-state object shows. + Content::Group { .. } => { + let shown: Vec<_> = it.shown_children().collect(); + shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)) + } Content::Graphic(g) => { let inner = xf.inverse() * at; if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { return None; } + // A clipping path hides the image outside it. + if let Some((region, even_odd)) = g.clip_region() { + let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero }; + if !designcraft_geom::hit::fill_contains(®ion, rule, inner) { + return None; + } + } let gp = g.xf.inverse() * inner; let (w, h) = g.size; // Written so that NaN (a degenerate transform) is outside too. @@ -2317,6 +2339,35 @@ mod sample_color_tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + #[test] + fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() { + let (mut s, image) = session_with_image(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap(); + // The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only. + s.edit(|d, _| { + let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?; + let b = it.inner_bounds(); + it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0)); + let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) }; + let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1)); + g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left))); + Ok(()) + }) + .unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap(); + assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled"); + // The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the + // frame's attributes apply. + for at in [[180, 100], [100, 180]] { + assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}"); + s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap(); + s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}"); + } + } + #[test] fn sampling_and_explicit_colour_use_the_same_text_application_path() { for command in ["color.sample", "object.color"] { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6f070ee67..400c33712 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1441,6 +1441,10 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let cx = ctx(&d2, &s, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); + // An image the pointer can reach names its frame, whose attributes apply where no pixel shows. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + let sampled = serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "matchFrom": photo.0}); + assert_eq!(a, vec![Action::Exec("color.sample".into(), sampled)]); // Vector graphics and missing assets must retain the ordinary attribute-matching path. for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] { std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into(); diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index e0bcd24f3..b318b4709 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -257,8 +257,10 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { return None; } let xf = outer * it.xf; - if let Content::Group { items } = &it.content { - return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); + if let Content::Group { .. } = &it.content { + // Only the active state of a multi-state object shows. + let shown: Vec<_> = it.shown_children().collect(); + return shown.into_iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); } let graphic = matches!(it.content, Content::Graphic(_)); // Unfilled frames (a text frame over the photo) let the image show through. @@ -297,11 +299,17 @@ impl Tool for EyedropperTool { return vec![]; } let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); + let hit = cx.hit(ev.pos).map(|(_, id)| id); // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). if selected && let Some((sr, at)) = image_under(cx, ev.pos) { - return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + // Where the image shows no opaque pixel, the engine matches the clicked object instead. + let mut p = json!({"at": [at.x, at.y], "spread": sr}); + if let Some(id) = hit { + p["matchFrom"] = json!(id.0); + } + return vec![Action::Exec("color.sample".into(), p)]; } - let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; + let Some(id) = hit else { return vec![] }; if !selected { return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; }