Graph series: find series art in one pass and ignore indices past the series
Painting looked up each selected object's graph by walking up through parent_of, a search of the whole document per level, so a fill or stroke change on a large selection grew with the square of the document. The series art of every graph is now mapped in one pass, and documents with no graph skip the lookup. A series index comes from the file: one past the graph's series grew the graph's series paints to that length. Such a group is no longer taken for a series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5c9ffc69e8
commit
f21f8a89ce
|
|
@ -226,7 +226,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
|||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (a series' fill and stroke persist through regenerate and save), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! the spec. Editing the data or the type regenerates them in place, keeping any move/scale the
|
||||
//! user applied to the graph since it was generated.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -576,46 +577,50 @@ fn spec_of(s: &Session, id: NodeId) -> Result<GraphSpec> {
|
|||
s.doc()?.doc.node(id).and_then(|n| n.graph.as_deref().cloned()).ok_or(EngineError::NoNode(id))
|
||||
}
|
||||
|
||||
/// The generated series group containing `id`: the graph, the group, and the series index stored
|
||||
/// on that group. Axes, the legend and a series that was not generated are not series groups.
|
||||
fn series_group(doc: &Document, id: NodeId) -> Option<(NodeId, NodeId, usize)> {
|
||||
let mut child = id;
|
||||
let mut parent = doc.parent_of(child);
|
||||
while let Some(parent_id) = parent {
|
||||
let node = doc.node(parent_id)?;
|
||||
if node.graph.is_some() {
|
||||
let group = node.children()?.iter().find(|n| n.id == child)?;
|
||||
let index = usize::try_from(group.series_index?).ok()?;
|
||||
return Some((parent_id, child, index));
|
||||
/// Where a node inside a graph's generated series art belongs: the graph, the series group and
|
||||
/// the series index stored on that group.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SeriesRef {
|
||||
graph: NodeId,
|
||||
group: NodeId,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
/// Every node in a graph's series groups (the groups too), found in one pass over the document.
|
||||
/// Axes, the legend and groups whose index is past the graph's series (the index comes from the
|
||||
/// file) are left out.
|
||||
fn series_members(doc: &Document) -> HashMap<NodeId, SeriesRef> {
|
||||
let mut out = HashMap::new();
|
||||
doc.walk(|n| {
|
||||
let Some(spec) = n.graph.as_deref() else { return };
|
||||
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
|
||||
for group in n.children().into_iter().flatten() {
|
||||
let Some(index) = group.series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue };
|
||||
let r = SeriesRef { graph: n.id, group: group.id, index };
|
||||
group.walk(&mut |m| {
|
||||
out.insert(m.id, r);
|
||||
});
|
||||
}
|
||||
child = parent_id;
|
||||
parent = doc.parent_of(child);
|
||||
}
|
||||
None
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Group Selection on a graph series targets its constituent marks and legend swatch, not a
|
||||
/// group-level appearance which the generated art does not inherit.
|
||||
/// group-level appearance which the generated art does not inherit. Other ids pass through.
|
||||
pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
fn paths(node: &vectorcraft_doc::Node, out: &mut Vec<NodeId>) {
|
||||
if matches!(node.kind, NodeKind::Path { .. }) {
|
||||
out.push(node.id);
|
||||
} else if let Some(children) = node.children() {
|
||||
for child in children {
|
||||
paths(child, out);
|
||||
}
|
||||
}
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return ids.to_vec();
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for id in ids {
|
||||
if let Some((_, group, _)) = series_group(doc, *id)
|
||||
&& group == *id
|
||||
&& let Some(node) = doc.node(group)
|
||||
{
|
||||
paths(node, &mut out);
|
||||
} else {
|
||||
out.push(*id);
|
||||
match members.get(id).filter(|r| r.group == *id).and_then(|_| doc.node(*id)) {
|
||||
Some(group) => group.walk(&mut |m| {
|
||||
if matches!(m.kind, NodeKind::Path { .. }) {
|
||||
out.push(m.id);
|
||||
}
|
||||
}),
|
||||
None => out.push(*id),
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
|
|
@ -627,18 +632,23 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
|||
/// inside the same document edit, so the visible paint and future regeneration undo together.
|
||||
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
|
||||
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) {
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return;
|
||||
}
|
||||
let captures: Vec<_> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (graph, _, index) = series_group(doc, *id)?;
|
||||
let r = members.get(id)?;
|
||||
let node = doc.node(*id)?;
|
||||
let paint = node.appearance.paint_at(None, fill)?.clone();
|
||||
let width = (!fill).then(|| node.appearance.stroke_width());
|
||||
Some((graph, index, paint, width))
|
||||
Some((r.graph, r.index, paint, width))
|
||||
})
|
||||
.collect();
|
||||
for (graph, index, paint, width) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue };
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
if spec.series_paints.len() <= index {
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
}
|
||||
|
|
@ -842,6 +852,26 @@ mod tests {
|
|||
assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green)));
|
||||
}
|
||||
|
||||
/// A series index read from a file past the graph's series is not a series: painting its art
|
||||
/// paints the art alone and stores nothing (no allocation sized by the file).
|
||||
#[test]
|
||||
fn a_series_index_past_the_graphs_series_is_ignored() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
|
||||
s.edit("Junk", |d, _| {
|
||||
d.node_mut(series_id).unwrap().series_index = Some(u32::MAX);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id;
|
||||
s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap();
|
||||
let node = s.doc().unwrap().doc.node(id).unwrap();
|
||||
assert!(node.graph.as_ref().unwrap().series_paints.is_empty());
|
||||
assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painting_a_line_series_stroke_sets_its_persistent_colour() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
Loading…
Reference in New Issue