Appearance and Eyedropper: reading an object's paint does not measure its bounds

The Eyedropper, the Live Paint check for paint to sample, the Appearance
panel's Characters chips and the colors the Libraries panel adds from
the selection found the paint behind the Fill or Stroke proxy through
Node::proxy_paint, which measures the object's geometric bounds. They
now read it with Node::proxy_paint_only.

- Eyedropper: the test for an empty fill reads the paint without
  measuring. A sample measures the object only for a gradient or a
  pattern, whose color depends on the point; a solid color gives its
  color at once.
- Live Paint: the check for paint to sample reads the paint without
  measuring.
- Appearance panel: the Characters row's two chips read the paint
  without measuring.
- Libraries: adding a color or a pattern from the selection reads the
  paint without measuring; a gradient is still measured for its
  placement.

The paints and colors are the same as before. On an open path of
200,000 anchors, the Live Paint check and the Eyedropper's sample for a
point inside it take 0.004 ms together instead of 26.0 ms in a release
build.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-07 00:17:15 +07:00
committed by bflatastic
parent b8ec5f4b23
commit 42fe0bead3
5 changed files with 191 additions and 7 deletions
+5
View File
@@ -1036,6 +1036,11 @@ fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result<Paint> {
None => st.selection.objects.clone(),
};
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
// Only a gradient needs the object measured for its placement.
let paint = n.proxy_paint_only(stroke, None)?;
if !matches!(paint, Paint::Gradient(_)) {
return Some(paint.clone());
}
let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?;
// Type's characters paint in its own space: a gradient there takes its placement in the
// document, so that it keeps the direction it shows.
+60
View File
@@ -230,6 +230,66 @@ fn a_gradient_goes_into_a_library_and_paints_another_object() {
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Nope"})).is_err());
}
/// Compound paths, groups and colorized objects add the paint their Fill proxy shows: a color by
/// its hex, a gradient with its stops.
#[test]
fn colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects() {
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
let mut s = session();
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
let solid = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
let graded = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone();
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]);
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [solid]}));
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [graded]}));
let sources = [NodeId(solid.as_u64().unwrap()), NodeId(graded.as_u64().unwrap())];
// For each source: a compound path and a group painted as it is, and a gray rectangle
// colorized with its fill.
let mut made: Vec<(NodeId, bool)> = vec![];
s.edit("Make", |d, _| {
let l = d.layers[0].id;
for (k, src) in sources.into_iter().enumerate() {
let source = d.node(src).unwrap().clone();
let fill = source.appearance.fill().unwrap().paint.clone();
let gradient = matches!(fill, Paint::Gradient(_));
let member = |d: &mut vectorcraft_doc::Document| {
let mut m = source.clone();
(m.id, m.appearance) = (d.alloc_id(), Default::default());
Arc::new(m)
};
let (a, b) = (member(d), member(d));
let mut compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![a], rule: Default::default() });
let mut group = Node::group(d.alloc_id(), vec![b]);
compound.appearance = source.appearance.clone();
group.appearance = source.appearance.clone();
let mut colorized = source.clone();
colorized.id = d.alloc_id();
colorized.appearance.set_fill(Paint::solid(Color::rgb8(128, 128, 128)));
colorized.extras_mut().colorize = Some(Box::new(fill));
for (j, n) in [compound, group, colorized].into_iter().enumerate() {
made.push((n.id, gradient));
d.insert(Some(l), 2 + 3 * k + j, n).unwrap();
}
}
Ok(())
})
.unwrap();
for (id, gradient) in made {
run(&mut s, "select.set", json!({"ids": [id.0]}));
if gradient {
run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"}));
let got = run(&mut s, "library.get", json!({"library": lib}));
let last = &got["gradients"].as_array().unwrap().last().unwrap()["gradient"]["gradient"]["stops"];
assert_eq!(last.as_array().map(Vec::len), Some(3), "{id:?}: {got}");
} else {
let added = run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
assert_eq!(added["name"], "#123456", "{id:?}");
}
}
}
/// A gradient added from an object that was rotated keeps the direction it shows there.
#[test]
fn a_gradient_from_rotated_art_keeps_its_direction() {
+124 -6
View File
@@ -257,20 +257,19 @@ fn symbol_color_at(s: &mut Sampler, levels: &mut [Option<Document>], inst: &Node
/// 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())))
cx.doc.node(id).is_some_and(|n| [false, true].into_iter().any(|stroke| n.proxy_paint_only(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. A pattern gives the color
/// of its tile art at `p` ([`pattern_color_at`]).
fn paint_sample(s: &mut Sampler, levels: &mut [Option<Document>], 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 fill_none = n.proxy_paint_only(false, None).is_none_or(Paint::is_none);
let stroke = on_stroke || fill_none;
// Type takes its paint from its characters, and a colorized object from its colorize paint.
let items: Vec<usize> = n.appearance.items.iter().enumerate().filter(|(_, it)| it.is_fill() != stroke).map(|(i, _)| i).collect();
if items.is_empty() || matches!(n.kind, NodeKind::Text(_)) || (!stroke && n.extras().colorize.is_some()) {
let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?;
return paint_color(s, levels, paint, to_doc, bounds, p);
return proxy_color(s, levels, n, stroke, None, p);
}
// The visible fills (or strokes) from the top: where one leaves a gap, the next shows. Of a
// path's strokes, only the ones that reach `p` count, when any does (a click within the hit
@@ -289,14 +288,26 @@ fn paint_sample(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, on_s
}
}
for &i in visible.iter().rev() {
let Some((paint, to_doc, bounds)) = n.proxy_paint(stroke, Some(i)) else { continue };
if let Some(c) = paint_color(s, levels, paint, to_doc, bounds, p) {
if let Some(c) = proxy_color(s, levels, n, stroke, Some(i), p) {
return Some(c);
}
}
None
}
/// The color the paint behind `n`'s Fill (or Stroke) proxy for `item` ([`Node::proxy_paint`])
/// shows at `p`. Only a gradient or a pattern has `n` measured for the paint's space and box.
fn proxy_color(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, stroke: bool, item: Option<usize>, p: Point) -> Option<Color> {
match n.proxy_paint_only(stroke, item)? {
Paint::None => None,
Paint::Solid { color, .. } => Some(*color),
_ => {
let (paint, to_doc, bounds) = n.proxy_paint(stroke, item)?;
paint_color(s, levels, paint, to_doc, bounds, p)
}
}
}
/// The color `paint`, in a space `to_doc` places in the document and with box `bounds` there,
/// shows at `p`. A pattern gives the color of its tile art at `p` ([`pattern_color_at`]).
fn paint_color(s: &mut Sampler, levels: &mut [Option<Document>], paint: &Paint, to_doc: Affine, bounds: Rect, p: Point) -> Option<Color> {
@@ -1175,6 +1186,113 @@ mod tests {
assert_eq!((at(100.0), at(94.0)), (Some("#ff0000".to_string()), Some("#0000ff".to_string())));
}
/// The color of `n`'s fill (or stroke) at `p` from its measured proxy paints: the reference
/// [`paint_sample`] is checked against.
fn measured_sample(s: &mut Sampler, levels: &mut [Option<Document>], 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 stroke = on_stroke || fill_none;
let items: Vec<usize> = n.appearance.items.iter().enumerate().filter(|(_, it)| it.is_fill() != stroke).map(|(i, _)| i).collect();
if items.is_empty() || matches!(n.kind, NodeKind::Text(_)) || (!stroke && n.extras().colorize.is_some()) {
let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?;
return paint_color(s, levels, paint, to_doc, bounds, p);
}
let mut visible: Vec<usize> = items.into_iter().filter(|&i| n.appearance.items.get(i).is_some_and(|it| it.visible())).collect();
if stroke && let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }, Some(bp)) = (&n.kind, n.stroke_path()) {
let reach =
|i: &usize| matches!(n.appearance.items.get(*i), Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if st.hits(&bp, *rule, p, 0.0));
if visible.iter().any(&reach) {
visible.retain(&reach);
}
}
for &i in visible.iter().rev() {
let Some((paint, to_doc, bounds)) = n.proxy_paint(stroke, Some(i)) else { continue };
if let Some(c) = paint_color(s, levels, paint, to_doc, bounds, p) {
return Some(c);
}
}
None
}
/// Samples and the check for paint to sample give what the measured proxy paints give, on
/// paths, compound paths, groups, type and symbol instances whose fills and strokes have no
/// paint, a solid color or a gradient, in the top row and the row below, with and without a
/// colorize paint.
#[test]
fn samples_give_the_measured_proxy_paints_colors() {
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, CharStyle, FillLayer, StrokeLayer, TextObject};
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::rectangle;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let square = |id| Node::path(id, rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Default::default());
let ids: Vec<NodeId> = (0..5).map(|_| d.alloc_id()).collect();
let (outer, hole, inner) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let hole = Node::path(hole, rectangle(Rect::new(120.0, 120.0, 180.0, 180.0)), Default::default());
let text = TextObject::point(Point::new(100.0, 150.0), "Type", CharStyle { size: 40.0, ..Default::default() });
let objects = [
square(ids[0]),
Node::new(ids[1], NodeKind::Compound { children: vec![Arc::new(square(outer)), Arc::new(hole)], rule: Default::default() }),
Node::group(ids[2], vec![Arc::new(square(inner))]),
Node::new(ids[3], NodeKind::Text(Box::new(text))),
Node::new(ids[4], NodeKind::SymbolInstance { symbol: "Missing".into(), xf: Affine::translate((100.0, 100.0)) }),
];
for (k, n) in objects.into_iter().enumerate() {
d.insert(Some(l), k + 1, n).unwrap();
}
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
let paints = [Paint::None, Paint::solid(Color::rgb8(200, 40, 10)), gradient];
let points = [Point::new(150.0, 150.0), Point::new(100.0, 150.0), Point::new(130.0, 110.0)];
let (sel, pd) = (Selection::default(), paint());
let (mut checked, mut colored) = (0, 0);
for &id in &ids {
for top in &paints {
for below in &paints {
for colorize in [None, Some(&paints[1]), Some(&paints[2])] {
let mut d = d.clone();
let n = d.node_mut(id).unwrap();
n.appearance.items = vec![
AppearanceItem::Fill(FillLayer::new(below.clone())),
AppearanceItem::Stroke(StrokeLayer::new(below.clone(), 4.0)),
AppearanceItem::Fill(FillLayer::new(top.clone())),
AppearanceItem::Stroke(StrokeLayer::new(top.clone(), 2.0)),
];
n.extras_mut().colorize = colorize.map(|c| Box::new(c.clone()));
if let NodeKind::Text(t) = &mut n.kind {
for run in &mut t.runs {
(run.style.fill, run.style.stroke, run.style.stroke_width) = (top.clone(), below.clone(), 2.0);
}
}
let cx = cx(&d, &sel, &pd);
let n = d.node(id).unwrap();
let measured =
[false, true].into_iter().any(|stroke| n.proxy_paint(stroke, None).is_some_and(|(paint, ..)| !paint.is_none()));
assert_eq!(has_sampleable_paint(&cx, id), measured, "{n:?}");
for on_stroke in [false, true] {
for p in points {
let mut s = Sampler {
doc: &d,
opts: sample_options(&cx),
raster_sample: 1,
symbol_nodes: 0,
work: MAX_WORK,
exhausted: false,
patterns: 0,
};
let mut levels: Vec<Option<Document>> = (0..MAX_NESTING).map(|_| None).collect();
let sampled = paint_sample(&mut s, &mut levels, n, on_stroke, p);
assert_eq!(sampled, measured_sample(&mut s, &mut levels, n, on_stroke, p), "{n:?} stroke {on_stroke} at {p:?}");
checked += 1;
colored += usize::from(sampled.is_some());
}
}
}
}
}
}
assert!(colored > checked / 4 && colored < checked, "both answers come up: {colored} of {checked} gave a color");
}
/// A gap between a pattern's tile art shows what lies below it, in the document and in the tile
/// art of another pattern.
#[test]
+1 -1
View File
@@ -1035,7 +1035,7 @@ fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, se
if characters {
for (k, stroke) in [false, true].into_iter().enumerate() {
let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 100.0 + 24.0 * k as f32, r.center().y - 9.0), vec2(18.0, 18.0));
chip(ui, cr, n.proxy_paint(stroke, None).map_or(&Paint::None, |(p, ..)| p), false);
chip(ui, cr, n.proxy_paint_only(stroke, None).unwrap_or(&Paint::None), false);
}
}
// An image's row has no members to target.
+1
View File
@@ -553,6 +553,7 @@ since 2026-10-01.
- **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html).
- **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`.
The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`.
The Eyedropper, the Live Paint check for paint to sample, the Appearance panel's Characters chips and the colors the Libraries panel adds from the selection also read the proxy's paint without measuring the object; the Eyedropper measures it only to sample a gradient or a pattern, and the Libraries panel only for a gradient. The colors and paints are the same as before. With an open path of 200,000 anchors, the Live Paint check and the Eyedropper's sample for a point inside it take 0.004 ms together instead of 26.0 ms in a release build. Tests `samples_give_the_measured_proxy_paints_colors` and `colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects`.
- **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes.
- **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure.
- **Artboard clean active-tab close (2026-10-15):** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. Nine targeted checks passed: three existing tab regressions, one clean close/Delete regression and five existing unsaved-document controls. The close regression checks the correct surviving board, preserved artwork/symbols and object selection, affected guides, one Delete edit, saved state and Undo/Redo. Three reviewed original full-app headless captures show the active tab before closing, the surviving document's shown board after closing, and the correct survivor after Properties Delete, using the actual tab × and Delete controls.