mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Live Paint: Alt-click and Alt+Shift-click with the Bucket sample with the Eyedropper
With the Live Paint Bucket, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults, and on a placed image it samples the pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke. Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. While Alt is held, the Bucket shows the Eyedropper cursor and no face or edge highlight. appearance.copyFrom takes `defaultsOnly: true` to update only the paint defaults, and paint.sampleColor takes `clearStroke: true` to clear the stroke default after an image sample into the fill.
This commit is contained in:
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -840,11 +840,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
|
||||
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
|
||||
if defaults_only && (reverse || append || p.get("ids").is_some()) {
|
||||
return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`"));
|
||||
}
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
||||
let selected = match ids_param(p, "ids") {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() {
|
||||
super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let selected = if defaults_only {
|
||||
vec![]
|
||||
} else {
|
||||
match ids_param(p, "ids") {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
}
|
||||
};
|
||||
// Reversed, the first selected object's attributes go onto the clicked object; with nothing
|
||||
// selected, what the Eyedropper picked up last does (#4382).
|
||||
@@ -864,7 +876,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
|
||||
};
|
||||
let picked = Picked::of(&node);
|
||||
if !reverse {
|
||||
if !reverse && !defaults_only {
|
||||
s.eyedropper_sample = Some(node);
|
||||
}
|
||||
let range = range.filter(|(id, _, _)| targets.contains(id));
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sample Color",
|
||||
[],
|
||||
None,
|
||||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it",
|
||||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index, clearStroke?: bool} sample a color into the active fill/stroke (and the selection; `ids: []` changes only the paint default); `clearStroke: true` requires `stroke: false`, `ids: []` and no `stop`, and clears the default stroke after sampling an image into the fill; with `stop`, recolor that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it",
|
||||
has_doc,
|
||||
sample_color
|
||||
),
|
||||
@@ -343,7 +343,11 @@ fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.sampleColor";
|
||||
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
let stroke = super::switch_or(p, "stroke", !s.fill_active, C)?;
|
||||
let clear_stroke = super::switch_or(p, "clearStroke", false, C)?;
|
||||
if clear_stroke && (stroke || p.get("stroke") != Some(&Value::Bool(false)) || p.get("ids") != Some(&json!([])) || p.get("stop").is_some()) {
|
||||
return Err(bad(C, "`clearStroke` requires `stroke: false`, `ids: []` and no `stop`"));
|
||||
}
|
||||
let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
|
||||
let (id, mut q) = match p.get("stop") {
|
||||
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
|
||||
@@ -364,7 +368,11 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q["ids"] = ids.clone();
|
||||
}
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.run)(s, &q)
|
||||
let result = (spec.run)(s, &q)?;
|
||||
if clear_stroke {
|
||||
s.paint.stroke = Paint::None;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ---------- artboards ----------
|
||||
|
||||
@@ -2316,6 +2316,88 @@ fn live_paint_bucket_tool_fills_face() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
|
||||
let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.default", &json!({})).unwrap();
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone());
|
||||
let face_before = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap();
|
||||
assert_eq!(s.paint.fill.color(), Some(Color::rgb8(255, 0, 0)));
|
||||
assert_eq!(s.paint.stroke.color(), Some(Color::rgb8(0, 0, 255)));
|
||||
assert_eq!(s.paint.stroke_width, 12.0);
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before);
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(Color::WHITE));
|
||||
assert_eq!(node(&s, src).appearance.fill_paint().color(), Some(Color::rgb8(255, 0, 0)));
|
||||
assert_eq!(vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint(), face_before);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap();
|
||||
assert_eq!(
|
||||
vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint().color(),
|
||||
Some(Color::rgb8(255, 0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_image_samples_fill_and_clears_stroke() {
|
||||
let mut s = session();
|
||||
let img = image::RgbaImage::from_raw(1, 1, vec![0, 255, 0, 255]).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, _| {
|
||||
d.images.insert("px".into(), ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject {
|
||||
key: "px".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
xf: Affine::translate((300.0, 0.0)) * Affine::scale(100.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| EngineError::Other(e.to_string()))
|
||||
})
|
||||
.unwrap();
|
||||
let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone());
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap();
|
||||
assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0)));
|
||||
assert!(s.paint.stroke.is_none());
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before);
|
||||
assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::rgb8(0, 0, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap();
|
||||
let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#ffffff"})).unwrap();
|
||||
let fill = s.paint.fill.clone();
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone());
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, shift: true, ..Default::default() }), v).unwrap();
|
||||
let sampled = s.paint.stroke.color().unwrap().to_rgb();
|
||||
assert!(sampled[0] > 0.0 && sampled[2] > 0.0 && sampled[1] < 0.01, "{sampled:?}");
|
||||
assert_eq!(s.paint.fill, fill);
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before);
|
||||
assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::WHITE));
|
||||
assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_marquee_selects_what_it_touches() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -68,6 +68,113 @@ fn picking_up_without_weight_keeps_the_target_weight() {
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"nope": true}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_attribute_sample_changes_only_defaults_and_respects_options() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
let art = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = undo_len(&s);
|
||||
s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"stroke": {"weight": false}}}})).unwrap();
|
||||
let width = s.paint.stroke_width;
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(s.paint.stroke.color().unwrap().to_hex(), "#0000ff");
|
||||
assert_eq!(s.paint.stroke_width, width);
|
||||
assert_eq!(s.doc().unwrap().doc, art);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#ffffff");
|
||||
for extra in [json!({"ids": []}), json!({"reverse": true}), json!({"append": true})] {
|
||||
let mut p = json!({"source": src.0, "defaultsOnly": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("appearance.copyFrom", &p).is_err());
|
||||
}
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": "yes"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, art);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_attribute_sample_does_not_replace_last_eyedropper_sample() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_attribute_sample_does_not_commit_highlighted_type() {
|
||||
let mut s = session();
|
||||
s.prefs.placeholder_text = false;
|
||||
let src = rect(&mut s, 300.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let view = ViewInfo::default();
|
||||
s.select_tool("type", view).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
|
||||
}
|
||||
let dst = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("ABCD", view).unwrap();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 2}));
|
||||
let before = node(&s, dst);
|
||||
let undo = undo_len(&s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
assert_eq!(node(&s, dst), before);
|
||||
assert_eq!(undo_len(&s), undo, "sampling does not commit pending typing");
|
||||
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_color_sample_changes_only_defaults_and_supplies_next_fill() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 300.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let dst = rect(&mut s, 10.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [dst.0]})).unwrap();
|
||||
let dst_before = node(&s, dst);
|
||||
let art = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_len(&s);
|
||||
s.execute("paint.sampleColor", &json!({"color": "#00ff00", "ids": []})).unwrap();
|
||||
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#00ff00");
|
||||
assert_eq!(node(&s, dst), dst_before);
|
||||
assert_eq!(s.doc().unwrap().doc, art);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
assert_eq!(node(&s, dst), dst_before);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
let r = s.execute("livePaint.fill", &json!({"ids": [dst.0], "point": [35, 35]})).unwrap();
|
||||
let face = NodeId(r["face"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, face).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_color_sample_sets_only_fill_default_and_clears_stroke() {
|
||||
let mut s = session();
|
||||
let dst = rect(&mut s, 10.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
let before = (node(&s, dst), s.doc().unwrap().selection.clone());
|
||||
s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stroke": false, "ids": [], "clearStroke": true})).unwrap();
|
||||
assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0)));
|
||||
assert!(s.paint.stroke.is_none());
|
||||
assert_eq!((node(&s, dst), s.doc().unwrap().selection.clone()), before);
|
||||
let state = (s.paint.clone(), node(&s, dst));
|
||||
for p in [
|
||||
json!({"color": "#ffffff", "stroke": true, "ids": [], "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "ids": [], "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "ids": [dst.0], "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "ids": [], "stop": 0, "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "ids": [], "clearStroke": "yes"}),
|
||||
json!({"color": "#ffffff", "stroke": "no", "ids": [], "clearStroke": true}),
|
||||
json!({"color": {"bad": true}, "stroke": false, "ids": [], "clearStroke": true}),
|
||||
] {
|
||||
assert!(s.execute("paint.sampleColor", &p).is_err(), "{p}");
|
||||
assert_eq!((s.paint.clone(), node(&s, dst)), state, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_appearance_attribute_copies_the_whole_stack() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -87,13 +87,14 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) ->
|
||||
}
|
||||
|
||||
/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click
|
||||
/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a
|
||||
/// Live Paint group first.
|
||||
/// paints the nearest edge with the current stroke. Alt-click samples a fill or stroke into the
|
||||
/// paint defaults; Alt+Shift-click samples only the color under the pointer. Clicking selected
|
||||
/// ordinary paths without Alt makes them a Live Paint group first.
|
||||
pub struct LivePaintBucketTool {
|
||||
pub(super) cache: RegionCache,
|
||||
pub(super) hover: LpHover,
|
||||
/// Where the pointer last moved, and whether Shift was held.
|
||||
at: Option<(Point, bool)>,
|
||||
/// Where the pointer last moved, and whether Shift and Alt were held.
|
||||
at: Option<(Point, bool, bool)>,
|
||||
}
|
||||
|
||||
impl Default for LivePaintBucketTool {
|
||||
@@ -111,12 +112,35 @@ impl Tool for LivePaintBucketTool {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.at = Some((p, ev.mods.shift));
|
||||
self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id));
|
||||
self.at = Some((p, ev.mods.shift, ev.mods.alt));
|
||||
self.hover =
|
||||
if ev.mods.alt { LpHover::None } else { lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id)) };
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.hover = LpHover::None;
|
||||
if ev.mods.alt {
|
||||
if ev.mods.shift {
|
||||
return crate::xform::eyedropper::sample_color_at(cx, p).map_or_else(Vec::new, |color| {
|
||||
vec![Action::Exec("paint.sampleColor".into(), json!({ "color": crate::params::color_json(&color), "ids": [] }))]
|
||||
});
|
||||
}
|
||||
return crate::xform::eyedropper::sample_source(cx, p).map_or_else(Vec::new, |(source, _)| {
|
||||
if cx.doc.node(source).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) {
|
||||
return crate::xform::eyedropper::sample_color_at(cx, p).map_or_else(Vec::new, |color| {
|
||||
vec![Action::Exec(
|
||||
"paint.sampleColor".into(),
|
||||
json!({ "color": crate::params::color_json(&color), "stroke": false, "ids": [], "clearStroke": true }),
|
||||
)]
|
||||
});
|
||||
}
|
||||
if crate::xform::eyedropper::has_sampleable_paint(cx, source) {
|
||||
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": source.0, "defaultsOnly": true }))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
});
|
||||
}
|
||||
let point = json!([p.x, p.y]);
|
||||
if ev.mods.shift {
|
||||
return match lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) {
|
||||
@@ -143,8 +167,8 @@ impl Tool for LivePaintBucketTool {
|
||||
lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0)
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::LivePaintBucket
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
|
||||
if m.alt { Cursor::Eyedropper } else { Cursor::LivePaintBucket }
|
||||
}
|
||||
|
||||
fn waiting(&self) -> bool {
|
||||
@@ -159,8 +183,8 @@ impl Tool for LivePaintBucketTool {
|
||||
// of a Live Paint group the hover takes no map.
|
||||
loop {
|
||||
self.cache.settle(wait);
|
||||
if let Some((p, shift)) = self.at {
|
||||
self.hover = lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id));
|
||||
if let Some((p, shift, alt)) = self.at {
|
||||
self.hover = if alt { LpHover::None } else { lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id)) };
|
||||
}
|
||||
if !wait || !self.cache.waiting() {
|
||||
break;
|
||||
|
||||
@@ -765,6 +765,125 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() {
|
||||
assert_eq!(id, "livePaint.strokeEdge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_alt_samples_face_edge_and_selected_region_without_painting() {
|
||||
let (mut d, _gid, face, edge) = live_group_doc();
|
||||
d.node_mut(face).unwrap().appearance.set_fill(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE));
|
||||
d.node_mut(edge).unwrap().appearance.set_stroke(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(alt)),
|
||||
vec![Action::Exec("appearance.copyFrom".into(), json!({"source": face.0, "defaultsOnly": true}))]
|
||||
);
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 25.0).with_mods(alt)),
|
||||
vec![Action::Exec("appearance.copyFrom".into(), json!({"source": edge.0, "defaultsOnly": true}))]
|
||||
);
|
||||
|
||||
let (d, a, b) = two_rects();
|
||||
let mut s = Selection::default();
|
||||
s.set([a, b]);
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0).with_mods(alt)),
|
||||
vec![Action::Exec("appearance.copyFrom".into(), json!({"source": b.0, "defaultsOnly": true}))]
|
||||
);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(alt)).is_empty());
|
||||
let (d, _, _, _) = live_group_doc();
|
||||
assert!(t.pointer(&crate::testutil::cx(&d, &Selection::default(), &p), &ev(PointerKind::Down, 75.0, 25.0).with_mods(alt)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() {
|
||||
let (mut d, _gid, face, _edge) = live_group_doc();
|
||||
d.node_mut(face).unwrap().appearance.set_fill(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let both = Mods { alt: true, shift: true, ..Default::default() };
|
||||
let pos = Point::new(75.0, 25.0);
|
||||
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket);
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
|
||||
assert!(!t.overlays(&cx).is_empty());
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0).with_mods(alt));
|
||||
assert_eq!(t.cursor(&cx, pos, alt), Cursor::Eyedropper);
|
||||
assert_eq!(t.id(), "livePaintBucket");
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(both)),
|
||||
vec![Action::Exec("paint.sampleColor".into(), json!({"color": crate::params::color_json(&vectorcraft_color::Color::WHITE), "ids": []}))]
|
||||
);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(both)).is_empty());
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
|
||||
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket);
|
||||
assert!(!t.overlays(&cx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_alt_shift_samples_gradient_image_and_stroke_color() {
|
||||
let (mut d, _a, b) = two_rects();
|
||||
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
d.node_mut(b).unwrap().appearance.set_fill(gradient.clone());
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let both = Mods { alt: true, shift: true, ..Default::default() };
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
assert_eq!(
|
||||
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 100.0, 50.0).with_mods(both)),
|
||||
vec![Action::Exec(
|
||||
"paint.sampleColor".into(),
|
||||
json!({"color": crate::params::color_json(&vectorcraft_color::Color::rgb(0.5, 0.5, 0.5)), "ids": []})
|
||||
)]
|
||||
);
|
||||
|
||||
let (mut d, _gid, _face, edge) = live_group_doc();
|
||||
d.node_mut(edge).unwrap().appearance.set_stroke(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
assert_eq!(
|
||||
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 50.0, 25.0).with_mods(both)),
|
||||
vec![Action::Exec("paint.sampleColor".into(), json!({"color": crate::params::color_json(&vectorcraft_color::Color::BLACK), "ids": []}))]
|
||||
);
|
||||
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
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(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "px".into(),
|
||||
width: 3,
|
||||
height: 1,
|
||||
xf: vectorcraft_geom::Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.raster_sample = 3;
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 15.0, 5.0).with_mods(Mods { alt: true, ..Default::default() })),
|
||||
vec![Action::Exec(
|
||||
"paint.sampleColor".into(),
|
||||
json!({"color": crate::params::color_json(&vectorcraft_color::Color::rgb8(85, 85, 85)), "stroke": false, "ids": [], "clearStroke": true})
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 15.0, 5.0).with_mods(both)),
|
||||
vec![Action::Exec(
|
||||
"paint.sampleColor".into(),
|
||||
json!({"color": crate::params::color_json(&vectorcraft_color::Color::rgb8(85, 85, 85)), "ids": []})
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_on_plain_selection_makes_live_paint() {
|
||||
let (d, a, b) = two_rects();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! on the interaction snapshot, the utility tools execute selection / paint / artboard commands.
|
||||
|
||||
mod artboard;
|
||||
mod eyedropper;
|
||||
pub(crate) mod eyedropper;
|
||||
mod free;
|
||||
mod freeform;
|
||||
mod gradient;
|
||||
|
||||
@@ -49,6 +49,25 @@ pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p:
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible object under `p`, using the Eyedropper's source selection rules.
|
||||
pub(crate) fn sample_source(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, HitKind)> {
|
||||
let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() };
|
||||
let h = hit_test_visible(cx.doc, p, opts)?;
|
||||
Some((paint_owner(cx.doc, h.leaf), h.kind))
|
||||
}
|
||||
|
||||
/// The visible color under `p`, including image pixels and intermediate gradient colors.
|
||||
pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option<Color> {
|
||||
let (id, kind) = sample_source(cx, p)?;
|
||||
let n = cx.doc.node(id)?;
|
||||
if matches!(n.kind, NodeKind::Image(_)) { image_color_at(cx, n, p) } else { paint_sample(n, kind == HitKind::Stroke, p) }
|
||||
}
|
||||
|
||||
/// Whether the source has fill or stroke paint for an attribute sample.
|
||||
pub(crate) fn has_sampleable_paint(cx: &ToolContext, id: vectorcraft_doc::NodeId) -> bool {
|
||||
cx.doc.node(id).is_some_and(|n| [false, true].into_iter().any(|stroke| n.proxy_paint(stroke, None).is_some_and(|(paint, ..)| !paint.is_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> {
|
||||
@@ -91,13 +110,7 @@ impl Tool for EyedropperTool {
|
||||
// Object Selection by Path Only say.
|
||||
let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() };
|
||||
if self.brush_key_color {
|
||||
let h = hit_test_visible(cx.doc, ev.pos, opts);
|
||||
let Some(h) = h else { return vec![] };
|
||||
let src = paint_owner(cx.doc, h.leaf);
|
||||
let Some(n) = cx.doc.node(src) else { return vec![] };
|
||||
let image = matches!(n.kind, NodeKind::Image(_));
|
||||
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 Some(c) = sample_color_at(cx, ev.pos) else { return vec![] };
|
||||
let back = self.stop.clone().unwrap_or_else(|| "selection".to_string());
|
||||
self.brush_key_color = false;
|
||||
self.stop = None;
|
||||
@@ -106,9 +119,12 @@ impl Tool for EyedropperTool {
|
||||
let stop = self.stop.as_ref().zip(cx.gradient_stop);
|
||||
// Reading a source is independent of its editability. Only Alt's reverse operation
|
||||
// writes to the clicked object; Shift+Alt appends its appearance to the selection.
|
||||
let h = if ev.mods.alt && !ev.mods.shift && stop.is_none() { hit_test(cx.doc, ev.pos, opts) } else { hit_test_visible(cx.doc, ev.pos, opts) };
|
||||
let Some(h) = h else { return vec![] };
|
||||
let src = paint_owner(cx.doc, h.leaf);
|
||||
let source = if ev.mods.alt && !ev.mods.shift && stop.is_none() {
|
||||
hit_test(cx.doc, ev.pos, opts).map(|h| (paint_owner(cx.doc, h.leaf), h.kind))
|
||||
} else {
|
||||
sample_source(cx, ev.pos)
|
||||
};
|
||||
let Some((src, kind)) = source else { return vec![] };
|
||||
let Some(n) = cx.doc.node(src) else { return vec![] };
|
||||
if ev.mods.alt && stop.is_none() {
|
||||
let how = if ev.mods.shift { "append" } else { "reverse" };
|
||||
@@ -118,7 +134,7 @@ impl Tool for EyedropperTool {
|
||||
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 sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, 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 }))] };
|
||||
|
||||
@@ -506,6 +506,26 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_modifier_only_hover_updates_the_visible_cursor() {
|
||||
use egui::{Event, Modifiers};
|
||||
use vectorcraft_tools::Cursor;
|
||||
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool("livePaintBucket");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let at = app.canvas_rect.unwrap().center();
|
||||
let shown = |app: &mut VectorcraftApp, events| {
|
||||
frame(app, &ctx, events);
|
||||
app.canvas.hover_cursor.map(|(_, _, cursor)| cursor)
|
||||
};
|
||||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(at)]), Some(Cursor::LivePaintBucket));
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT)]), Some(Cursor::Eyedropper));
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]), Some(Cursor::LivePaintBucket));
|
||||
}
|
||||
|
||||
/// The Image Trace panel's eye-button hold ends when the pointer is released, even when the
|
||||
/// panel stopped drawing while the button was held, so a later press on the canvas does not
|
||||
/// show the source image.
|
||||
|
||||
+7
-1
@@ -2245,7 +2245,13 @@ reads as Add.
|
||||
The Eyedropper: `appearance.copyFrom {source}` copies what the Eyedropper Options pick up and apply from `source` to the
|
||||
selection (`ids`); `reverse: true` copies the selection's attributes onto `source` (Alt-click), or with nothing selected
|
||||
what the last copy without `reverse` picked up (a copy made then, kept for the session), and `append: true` adds
|
||||
the source's fills and strokes on top of each target's stack (Shift+Alt-click). The options live in the preferences:
|
||||
the source's fills and strokes on top of each target's stack (Shift+Alt-click). `defaultsOnly: true` copies the enabled
|
||||
fill, stroke and weight into paint defaults without changing selected art; it cannot be combined with `ids`, `reverse`
|
||||
or `append`. The Live Paint Bucket uses this for Alt-click sampling on vector art. On an image, it uses
|
||||
`paint.sampleColor {color, stroke: false, ids: [], clearStroke: true}` to set a solid fill default from the sampled
|
||||
pixel and clear the stroke default. `clearStroke: true` requires `stroke: false`, `ids: []` and no `stop`.
|
||||
Alt+Shift-click samples only the color at the pointer into the active paint default through
|
||||
`paint.sampleColor {color, ids: []}`. The options live in the preferences:
|
||||
`eyedropper.setOptions {sampleSize?, pickUp?, apply?}` (or `prefs.get`/`prefs.set` with key `eyedropper`) reads and
|
||||
sets them; a tree names flags under `appearance` (`transparency`, `fill` `{color, transparency, overprint}`, `stroke`
|
||||
`{color, transparency, overprint, weight, cap, join, miter, dash}`), `character` and `paragraph`, and a bool for a
|
||||
|
||||
@@ -896,6 +896,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
- Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged.
|
||||
Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`.
|
||||
With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`.
|
||||
Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off.
|
||||
View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)).
|
||||
Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`).
|
||||
|
||||
Reference in New Issue
Block a user