mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
M3.52: Eyedropper Options with Picks Up / Applies trees, Alt and Shift+Alt modes, image sampling
Double-clicking the Eyedropper opens Eyedropper Options (dialog eyedropperOptions): raster sample size (point, 3x3, 5x5) and Picks Up / Applies trees (transparency; focal fill colour, transparency, overprint; focal stroke colour, transparency, overprint, weight, cap, join, miter, dash; character and paragraph attributes). The options live in the preferences (Prefs::eyedropper, eyedropper.setOptions, prefs key eyedropper) and replace the earlier appearance/transparency switches. appearance.copyFrom takes pickUp/apply overrides, reverse (Alt-click: the selection's attributes onto the clicked object) and append (Shift+Alt-click: add the source's fills and strokes). Type copies font, size and paragraph attributes to type. Clicking an image samples its pixels; sampled colours keep their colour model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2e6662c93b
commit
303689c8f4
@@ -168,6 +168,9 @@ pub struct ToolContext<'a> {
|
||||
/// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the
|
||||
/// freeform annotator.
|
||||
pub freeform_point: Option<usize>,
|
||||
/// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper
|
||||
/// samples an image (1, 3 or 5).
|
||||
pub raster_sample: u32,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
@@ -353,6 +356,7 @@ pub(crate) mod testutil {
|
||||
appearance_item: None,
|
||||
constrain_angle: 0.0,
|
||||
freeform_point: None,
|
||||
raster_sample: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Eyedropper tool (I).
|
||||
//!
|
||||
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
|
||||
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
|
||||
//! the active fill/stroke (`paint.sampleColor`).
|
||||
//! Click an object: copy its attributes (what Eyedropper Options pick up and apply) to the
|
||||
//! selection and the paint defaults (`appearance.copyFrom`); Alt-click: the selection's attributes
|
||||
//! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the
|
||||
//! selection's (`append`). Shift-click: sample only the colour under the cursor into the active
|
||||
//! fill/stroke (`paint.sampleColor`, keeping the colour's model). Clicking an image samples its
|
||||
//! pixels (averaged over the options' raster sample size).
|
||||
//!
|
||||
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
|
||||
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
|
||||
@@ -11,9 +14,11 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::paint_owner;
|
||||
use crate::params::color_json;
|
||||
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -36,6 +41,26 @@ pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p:
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour of `n`'s fill (or its stroke, when the stroke was hit or there is no fill) at `p`,
|
||||
/// sampled in the paint's own space (text space for type runs) and box.
|
||||
fn paint_sample(n: &Node, on_stroke: bool, p: Point) -> Option<Color> {
|
||||
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
|
||||
let (paint, to_doc, bounds) = n.proxy_paint(on_stroke || fill_none, None)?;
|
||||
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * p } else { p };
|
||||
paint_color_at(paint, Some(bounds), p)
|
||||
}
|
||||
|
||||
/// The colour of image `n`'s pixels at `p`, averaged over the raster sample size.
|
||||
fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option<Color> {
|
||||
let NodeKind::Image(im) = &n.kind else { return None };
|
||||
if im.xf.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let px = im.xf.inverse() * p;
|
||||
let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample(px.x, px.y, cx.raster_sample)?;
|
||||
Some(Color::rgb8(r, g, b))
|
||||
}
|
||||
|
||||
impl Tool for EyedropperTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"eyedropper"
|
||||
@@ -50,18 +75,21 @@ impl Tool for EyedropperTool {
|
||||
let src = paint_owner(cx.doc, h.leaf);
|
||||
let Some(n) = cx.doc.node(src) else { return vec![] };
|
||||
let stop = self.stop.as_ref().zip(cx.gradient_stop);
|
||||
if ev.mods.shift || stop.is_some() {
|
||||
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
|
||||
let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] };
|
||||
// Sample in the paint's own space (text space for type runs) and box.
|
||||
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos };
|
||||
let Some(c) = paint_color_at(paint, Some(bounds), p) else { return vec![] };
|
||||
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))] };
|
||||
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex(), "stop": i })), Action::SwitchTool(back.clone())];
|
||||
self.stop = None;
|
||||
return out;
|
||||
if ev.mods.alt && stop.is_none() {
|
||||
let how = if ev.mods.shift { "append" } else { "reverse" };
|
||||
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0, how: true }))];
|
||||
}
|
||||
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]
|
||||
let image = matches!(n.kind, NodeKind::Image(_));
|
||||
if !(ev.mods.shift || stop.is_some() || image) {
|
||||
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))];
|
||||
}
|
||||
let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, h.kind == HitKind::Stroke, ev.pos) };
|
||||
let Some(c) = sampled else { return vec![] };
|
||||
let color = color_json(&c);
|
||||
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color }))] };
|
||||
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color, "stop": i })), Action::SwitchTool(back.clone())];
|
||||
self.stop = None;
|
||||
out
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
@@ -96,10 +124,10 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#ffffff"}))]);
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::WHITE)}))]);
|
||||
// On the stroke edge → the stroke colour.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000"}))]);
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::BLACK)}))]);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
|
||||
}
|
||||
|
||||
@@ -119,10 +147,68 @@ mod tests {
|
||||
// Empty canvas: still waiting.
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000", "stop": 1})), Action::SwitchTool("gradient".into())]);
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![
|
||||
Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&Color::BLACK), "stop": 1})),
|
||||
Action::SwitchTool("gradient".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(t.options(), json!({"stop": null}), "one sample only");
|
||||
}
|
||||
|
||||
/// The hex of the colour a `paint.sampleColor` action samples.
|
||||
fn sampled_hex(a: &[Action]) -> String {
|
||||
let [Action::Exec(c, p)] = a else { panic!("one action: {a:?}") };
|
||||
assert_eq!(c, "paint.sampleColor");
|
||||
let v: Vec<f32> = serde_json::from_value(p["color"].clone()).unwrap();
|
||||
Color::rgb(v[0], v[1], v[2]).to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_sends_reverse_and_shift_alt_append() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = EyedropperTool::default();
|
||||
let at = |m: Mods| PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(m);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
assert_eq!(t.pointer(&cx(&d, &s, &p), &at(alt)), vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0, "reverse": true}))]);
|
||||
let both = Mods { alt: true, shift: true, ..Default::default() };
|
||||
assert_eq!(t.pointer(&cx(&d, &s, &p), &at(both)), vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0, "append": true}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_keeps_the_colour_model() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||||
d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(cmyk));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": color_json(&cmyk)}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_an_image_samples_its_pixels() {
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
// Three pixels, red, green, blue, 10 pt each.
|
||||
let img = image::RgbaImage::from_raw(3, 1, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]].concat()).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("px".into(), ImageBlob::png(png));
|
||||
let im = ImageObject { key: "px".into(), width: 3, height: 1, xf: vectorcraft_geom::Affine::scale(10.0), link: None };
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
let click = PointerEvent::new(PointerKind::Down, 15.0, 5.0);
|
||||
assert_eq!(sampled_hex(&EyedropperTool::default().pointer(&cx, &click)), "#00ff00");
|
||||
cx.raster_sample = 3;
|
||||
assert_eq!(sampled_hex(&EyedropperTool::default().pointer(&cx, &click)), "#555555", "3 x 3 average");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_sampled_along_vector() {
|
||||
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
|
||||
@@ -150,7 +236,7 @@ mod tests {
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
// 80 % along the baseline (text space (80, -5)).
|
||||
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
|
||||
assert_eq!(sampled_hex(&a), "#333333");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user