mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
Radial gradients show a dashed extent ellipse: its handle across the bar sets the aspect ratio, the ellipse elsewhere rotates it, and the dot inside the centre ring drags the focal point (GradientGeom.focal) for an off-centre radial. paint.setGradientGeom takes aspect (%) and focal ([x,y] | null), and start/end may be left out; the focal point keeps its place in the ellipse as the vector, aspect and object transform. It renders as a two-point radial, exports and imports as SVG fx/fy and PDF two-point radial shadings, expands to off-centre rings, and travels in gradient params and the native format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
12 KiB
Rust
255 lines
12 KiB
Rust
//! Eyedropper tool (I).
|
|
//!
|
|
//! 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
|
|
//! {stop}`) and switches back to that tool.
|
|
|
|
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)]
|
|
pub struct EyedropperTool {
|
|
/// Sampling for the selected gradient stop: the tool to switch back to afterwards.
|
|
stop: Option<String>,
|
|
}
|
|
|
|
/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring
|
|
/// their aspect ratio).
|
|
pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p: Point) -> Option<Color> {
|
|
match paint {
|
|
Paint::Solid { color, .. } => Some(*color),
|
|
Paint::Gradient(g) => {
|
|
let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?;
|
|
let t = geom.param_at(g.gradient.kind, p);
|
|
Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|
|
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
if ev.kind != PointerKind::Down {
|
|
return vec![];
|
|
}
|
|
let opts = vectorcraft_doc::hit::HitOptions { path_only: false, ..cx.hit_options() };
|
|
let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] };
|
|
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.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 }))];
|
|
}
|
|
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 {
|
|
json!({ "stop": self.stop })
|
|
}
|
|
|
|
fn set_option(&mut self, key: &str, value: &Value) {
|
|
if key == "stop" {
|
|
self.stop = value.as_str().map(str::to_string);
|
|
}
|
|
}
|
|
|
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
|
Cursor::Eyedropper
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testutil::*;
|
|
use vectorcraft_doc::Selection;
|
|
|
|
#[test]
|
|
fn click_copies_appearance_shift_samples_color() {
|
|
let (d, id) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = EyedropperTool::default();
|
|
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
|
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": 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": color_json(&Color::BLACK)}))]);
|
|
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_stop_option_samples_into_the_selected_stop_then_switches_back() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
let mut t = EyedropperTool::default();
|
|
t.set_option("stop", &json!("gradient"));
|
|
assert_eq!(t.options(), json!({"stop": "gradient"}));
|
|
// Without a selected stop the click copies the appearance as usual.
|
|
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
|
assert!(matches!(&a[0], Action::Exec(c, _) if c == "appearance.copyFrom"));
|
|
cx.gradient_stop = Some(1);
|
|
// 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": 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());
|
|
let paint = Paint::Gradient(Box::new(g));
|
|
let r = vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0);
|
|
assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff");
|
|
assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000");
|
|
}
|
|
|
|
#[test]
|
|
fn type_gradients_are_sampled_in_text_space() {
|
|
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
|
|
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject};
|
|
let mut d = Document::new(500.0, 500.0);
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
// Type turned 90°: its baseline runs down from (100, 100).
|
|
let mut t = TextObject::point(Point::ZERO, "Gradient", CharStyle { size: 20.0, ..Default::default() });
|
|
t.xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(std::f64::consts::FRAC_PI_2);
|
|
let mut g = GradientPaint::new(Gradient::default());
|
|
g.geom = Some(GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 1.0, focal: None });
|
|
t.runs[0].style.fill = Paint::Gradient(Box::new(g));
|
|
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
|
let (s, p) = (Selection::default(), paint());
|
|
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!(sampled_hex(&a), "#333333");
|
|
}
|
|
|
|
#[test]
|
|
fn radial_sample_honours_aspect() {
|
|
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient {
|
|
kind: vectorcraft_color::GradientKind::Radial,
|
|
..Default::default()
|
|
});
|
|
// Radius 100 along x, 50 across: 50 pt above the centre is already the outer (black) edge.
|
|
g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5, focal: None });
|
|
let paint = Paint::Gradient(Box::new(g));
|
|
assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000");
|
|
assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080");
|
|
}
|
|
}
|