mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:42:33 +00:00
Merge main into feat/plugin-importers
This commit is contained in:
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
clear_appearance
|
||||
),
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -126,7 +126,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, 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, 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}",
|
||||
"{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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, 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). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
show_all_hidden
|
||||
),
|
||||
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Appearance",
|
||||
[],
|
||||
None,
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}",
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}",
|
||||
has_doc,
|
||||
transfer
|
||||
),
|
||||
@@ -551,12 +551,17 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
// Where the new fill went in on the active item's object, when it went above the active row.
|
||||
let mut above = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
// The objects that got a new topmost row of the kind: the series stores its paint even when the object showed it.
|
||||
let mut added = Vec::new();
|
||||
for (id, row) in ids.iter().zip(&per) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(i) = n.appearance.item_of_kind(*row, true)
|
||||
&& let Some(paint) = n.appearance.paint_at(Some(i), true).cloned()
|
||||
{
|
||||
n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint)));
|
||||
if let Some(kind) = top_row_kind(&n.appearance, i + 1) {
|
||||
added.push((*id, kind));
|
||||
}
|
||||
if active == Some(*id) {
|
||||
above = Some(i + 1);
|
||||
}
|
||||
@@ -591,10 +596,14 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
}
|
||||
} else {
|
||||
n.appearance.items.push(item);
|
||||
added.push((*id, fill));
|
||||
}
|
||||
}
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, added));
|
||||
super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(())
|
||||
})?;
|
||||
// A fill added above the active row becomes the active row; otherwise the rows above the new
|
||||
@@ -614,7 +623,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -626,15 +636,37 @@ fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than
|
||||
/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d`
|
||||
/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
|
||||
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
|
||||
d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0)
|
||||
d.node(id)
|
||||
.and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity))
|
||||
.unwrap_or(1.0)
|
||||
};
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
|
||||
}
|
||||
|
||||
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another paint, or a
|
||||
/// stroke another weight, in `d` than in `before` (no row counts as no paint), for
|
||||
/// [`super::graph::capture_series_top_paints`].
|
||||
fn top_paint_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| d.node(id).map(|n| super::graph::shown_paint(n, fill));
|
||||
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
|
||||
}
|
||||
|
||||
/// `rows` (each object with the kind of row) whose topmost visible row of that kind has paint: a command that wrote them
|
||||
/// replaces the paint another mark of the series left on the graph, even when the mark's own row comes out as it was.
|
||||
fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator<Item = (NodeId, bool)>) -> Vec<(NodeId, bool)> {
|
||||
rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect()
|
||||
}
|
||||
|
||||
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
|
||||
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
|
||||
let fill = ap.items.get(at)?.is_fill();
|
||||
(ap.items.iter().rposition(|it| it.is_fill() == fill && it.visible()) == Some(at)).then_some(fill)
|
||||
}
|
||||
|
||||
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
|
||||
/// group keeps no fill or stroke rows of its own.
|
||||
fn clear(n: &mut Node) {
|
||||
@@ -672,7 +704,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let changed = if copy { vec![target] } else { vec![source, target] };
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept))
|
||||
// The target last: on marks of one series, its paint is the one the series keeps.
|
||||
let mut rows = top_paint_changed(&before, d, &changed);
|
||||
rows.extend(painted_rows(d, both_kinds(&[target])));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||
@@ -726,7 +762,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -752,10 +789,6 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (id, at) in ids.iter().zip(&per) {
|
||||
let Some(&idx) = at.first() else { continue };
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let items = &n.appearance.items;
|
||||
if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) {
|
||||
edited.push((*id, fill));
|
||||
}
|
||||
if p.get("weight").is_none()
|
||||
&& let Some(pa) = &paint
|
||||
{
|
||||
@@ -785,10 +818,26 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
*vis = v;
|
||||
}
|
||||
let items = &n.appearance.items;
|
||||
if let Some(fill) =
|
||||
items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx))
|
||||
{
|
||||
edited.push((*id, fill));
|
||||
}
|
||||
}
|
||||
// The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again.
|
||||
let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
// The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when
|
||||
// it is drawn again, and hiding or showing a row changes which row that is.
|
||||
let mut opacities = top_opacity_changed(&before, d, &ids);
|
||||
if p.get("opacity").and_then(Value::as_f64).is_some() {
|
||||
opacities.extend(edited.iter().copied());
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &opacities);
|
||||
// A weight goes with the paint of its stroke; a stroke without paint leaves the series' paint alone.
|
||||
let written = edited.iter().copied().filter(|(_, fill)| paint.is_some() || (!*fill && p.get("weight").is_some()));
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, written));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
if let Some(pa) = &paint {
|
||||
s.remember_paint(pa);
|
||||
@@ -832,7 +881,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
@@ -862,6 +912,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
// Copies that became the topmost visible row of their kind: the series stores their paint even when the object
|
||||
// showed it.
|
||||
let mut added = Vec::new();
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let has_contents = n.contents_label().is_some();
|
||||
@@ -877,9 +930,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if active == Some(*id) {
|
||||
active_at = Some(at);
|
||||
}
|
||||
let count = copies.len();
|
||||
for (k, item) in copies.into_iter().enumerate() {
|
||||
ap.insert_item(at + k, item);
|
||||
}
|
||||
added.extend((at..at + count).filter_map(|k| top_row_kind(ap, k)).map(|kind| (*id, kind)));
|
||||
if let Some(c) = contents.filter(|_| has_contents) {
|
||||
ap.set_contents_at(c);
|
||||
}
|
||||
@@ -893,6 +948,10 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(item) = ap.items.get(i).cloned() {
|
||||
first_at.get_or_insert(i + 1);
|
||||
ap.insert_item(i + 1, item);
|
||||
// Copies below this one go in lower down, so a copy that is the topmost row of its kind stays it.
|
||||
if let Some(kind) = top_row_kind(ap, i + 1) {
|
||||
added.push((*id, kind));
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
@@ -901,6 +960,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no item at index {}", idx[0])));
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, added));
|
||||
super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(())
|
||||
})?;
|
||||
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
|
||||
@@ -918,11 +980,14 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
|
||||
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = require_targets(s, p, "appearance.showAllHidden")?;
|
||||
let before = s.doc()?.doc.clone();
|
||||
let ids = s.edit("Show All Hidden Attributes", |d, _| {
|
||||
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
|
||||
if shown.is_empty() {
|
||||
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown));
|
||||
super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown));
|
||||
Ok(shown)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -962,7 +1027,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
// The moved row stays the active one; the rows it passed shift by one.
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
@@ -1006,7 +1072,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept))
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, ids));
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
let m = idx.len();
|
||||
let landed = first_landed.unwrap_or(to);
|
||||
@@ -1160,8 +1227,41 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
picked.apply(n, &attrs, append);
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets));
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept))
|
||||
// The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds
|
||||
// only the colors of the visible rows the source has.
|
||||
let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke);
|
||||
let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind);
|
||||
let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible());
|
||||
let (paints, opacities) = if append {
|
||||
let rows = (fill.color && source_shows(true), stroke.color && source_shows(false));
|
||||
(rows, rows)
|
||||
} else {
|
||||
(
|
||||
(fill.color, stroke.color || (stroke.weight && source_has(false))),
|
||||
(fill.transparency && source_has(true), stroke.transparency && source_has(false)),
|
||||
)
|
||||
};
|
||||
// A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows.
|
||||
let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL);
|
||||
let top_shows = |id: NodeId, kind: bool| {
|
||||
let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]);
|
||||
items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible())
|
||||
};
|
||||
let written = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
|
||||
targets
|
||||
.iter()
|
||||
.flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))])
|
||||
.flatten()
|
||||
.filter(|(id, kind)| rows_written || top_shows(*id, *kind))
|
||||
.collect()
|
||||
};
|
||||
let mut opacity_rows = top_opacity_changed(&before, d, &targets);
|
||||
opacity_rows.extend(painted_rows(d, written(opacities)));
|
||||
let mut rows = top_paint_changed(&before, d, &targets);
|
||||
rows.extend(painted_rows(d, written(paints)));
|
||||
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted))
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv};
|
||||
use generate::generate;
|
||||
#[cfg(test)]
|
||||
use series::default_series_color;
|
||||
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets};
|
||||
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::Value;
|
||||
use super::{GraphSpec, Value};
|
||||
|
||||
pub(super) const LABEL_SIZE: f64 = 9.0;
|
||||
|
||||
@@ -32,6 +32,11 @@ pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
|
||||
((lo / step).floor() * step, (hi / step).ceil() * step, step)
|
||||
}
|
||||
|
||||
/// The value axis of a radar graph: from 0 to the largest value of any series.
|
||||
pub(super) fn radar_axis(g: &GraphSpec) -> (f64, f64, f64) {
|
||||
value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max))
|
||||
}
|
||||
|
||||
/// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that
|
||||
/// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound
|
||||
/// was given.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, tick_values, value_axis};
|
||||
use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, radar_axis, tick_values, value_axis};
|
||||
use super::columns::{Allowance, ColumnArt, column_art, column_designs};
|
||||
use super::data::fmt_value;
|
||||
use super::labels::{
|
||||
FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of,
|
||||
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of,
|
||||
};
|
||||
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
|
||||
use super::series::{series_marks, with_series_opacity};
|
||||
@@ -264,7 +264,7 @@ impl<'a> Plot<'a> {
|
||||
right_edge: g.rect.x1,
|
||||
wedge_labels: vec![],
|
||||
on_top: vec![],
|
||||
filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET,
|
||||
filled: filled_lines_on(g, nser),
|
||||
bars_reach: None,
|
||||
shadowed: vec![],
|
||||
series: vec![vec![]; nser],
|
||||
@@ -422,7 +422,7 @@ impl<'a> Plot<'a> {
|
||||
let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs);
|
||||
let centre = r.center();
|
||||
let rad = (r.width().min(r.height()) / 2.0).max(1.0);
|
||||
let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max));
|
||||
let (lo, hi, step) = radar_axis(g);
|
||||
let at = |c: usize, v: f64| {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64;
|
||||
centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0))
|
||||
|
||||
@@ -90,9 +90,10 @@ impl Gen<'_> {
|
||||
};
|
||||
// A run the outline can't be made for (its points all in one place) stays a thin line.
|
||||
if !band.is_empty() {
|
||||
// The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline.
|
||||
// The band takes the series' color, set as a fill or, on a thin line, as a stroke with paint; a thin black outline.
|
||||
let slot = g.series_paints.get(s);
|
||||
let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s));
|
||||
let fill =
|
||||
slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone().filter(|st| !st.is_none()))).unwrap_or_else(|| default_series_paint(s));
|
||||
return self.path(band, fill, Paint::solid(Color::BLACK), 0.25);
|
||||
}
|
||||
let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0);
|
||||
@@ -140,7 +141,12 @@ pub(super) const SHADOW_OPACITY: f32 = 0.35;
|
||||
/// Draw Filled Lines: how wide a filled line is.
|
||||
const FILLED_LINE_WIDTH: f64 = 3.0;
|
||||
|
||||
pub(super) const FILLED_LINE_BUDGET: usize = 20_000;
|
||||
const FILLED_LINE_BUDGET: usize = 20_000;
|
||||
|
||||
/// Does graph `g`, with `series` series, draw Filled Lines (up to FILLED_LINE_BUDGET data points)?
|
||||
pub(super) fn filled_lines_on(g: &GraphSpec, series: usize) -> bool {
|
||||
g.filled_lines && g.rows.len().saturating_mul(series) <= FILLED_LINE_BUDGET
|
||||
}
|
||||
|
||||
/// Most marks a graph draws shadows for (each is a copy).
|
||||
pub(super) const SHADOW_BUDGET: usize = 200_000;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::labels::{Role, label_style};
|
||||
use super::axis::radar_axis;
|
||||
use super::labels::{Role, filled_lines_on, label_style};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
|
||||
// ---------- generation ----------
|
||||
@@ -121,11 +122,82 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
||||
let node = doc.node(*id)?;
|
||||
let paint = node.appearance.paint_at(None, fill)?.clone();
|
||||
let width = (!fill).then(|| node.appearance.stroke_width());
|
||||
Some((r.graph, r.index, paint, width))
|
||||
Some((r.graph, r.index, fill, paint, width))
|
||||
})
|
||||
.collect();
|
||||
store_series_paints(doc, captures)
|
||||
}
|
||||
|
||||
/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint).
|
||||
/// With no such row: no paint.
|
||||
pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
|
||||
match node.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()) {
|
||||
Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if !st.paint.is_none() => (st.paint.clone(), st.width),
|
||||
Some(it) => (it.paint().clone(), 0.0),
|
||||
None => (Paint::None, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series
|
||||
/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series'
|
||||
/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its
|
||||
/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series'
|
||||
/// captured fill or stroke changed.
|
||||
pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
|
||||
if edited.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Each series is checked once: the check reads the graph's whole grid.
|
||||
let mut bands: HashMap<(NodeId, usize), bool> = HashMap::new();
|
||||
let mut captures = Vec::new();
|
||||
for (id, fill) in edited {
|
||||
// Only the paths the graph draws again, not the copy of a graph design.
|
||||
let Some(r) = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group)) else { continue };
|
||||
let Some(node) = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. })) else { continue };
|
||||
let drawn = || doc.node(r.graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| series_draws_bands(g, r.index));
|
||||
if !*fill && *bands.entry((r.graph, r.index)).or_insert_with(drawn) {
|
||||
continue;
|
||||
}
|
||||
let (paint, width) = shown_paint(node, *fill);
|
||||
captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width)));
|
||||
}
|
||||
store_series_paints(doc, captures)
|
||||
}
|
||||
|
||||
/// Does series `i` draw any Filled Lines band? A line needs two neighboring values, a scatter series two neighboring points
|
||||
/// in different places (its index counts pairs of columns), and a radar series two categories with a value plotted away
|
||||
/// from the center.
|
||||
fn series_draws_bands(g: &GraphSpec, i: usize) -> bool {
|
||||
let series = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
if !g.connect_points || !filled_lines_on(g, series) {
|
||||
return false;
|
||||
}
|
||||
let cells = g.cells();
|
||||
let cell = |row: &[Option<f64>], s: usize| row.get(s).copied().flatten();
|
||||
match g.series_kind(i) {
|
||||
GraphKind::Line => cells.windows(2).any(|w| w.iter().all(|row| cell(row, i).is_some())),
|
||||
GraphKind::Radar => {
|
||||
let (lo, hi, _) = radar_axis(g);
|
||||
cells.len() > 1 && cells.iter().any(|row| (cell(row, i).unwrap_or(0.0) - lo) / (hi - lo) > 0.0)
|
||||
}
|
||||
GraphKind::Scatter => {
|
||||
let s = i.saturating_mul(2);
|
||||
let point = |row: &[Option<f64>]| Some((cell(row, s)?, cell(row, s.saturating_add(1))?));
|
||||
cells.windows(2).any(|w| matches!(w, [a, b] if point(a).is_some() && point(b).is_some() && point(a) != point(b)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
|
||||
/// series' captured fill or stroke changed.
|
||||
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
|
||||
let mut changed = false;
|
||||
for (graph, index, paint, width) in captures {
|
||||
for (graph, index, fill, paint, width) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let was = spec.series_paints.clone();
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
@@ -144,7 +216,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
||||
changed
|
||||
}
|
||||
|
||||
/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
|
||||
/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
|
||||
/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind
|
||||
/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity
|
||||
/// changed.
|
||||
@@ -162,7 +234,8 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
|
||||
// Only the paths the graph draws again, not the copy of a graph design.
|
||||
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
|
||||
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
|
||||
let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||||
let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible());
|
||||
let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
|
||||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -503,6 +503,819 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
|
||||
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
|
||||
}
|
||||
|
||||
/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight.
|
||||
fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, index).children().unwrap().to_vec();
|
||||
marks.iter().map(|m| (m.appearance.fill_paint(), m.appearance.stroke_paint(), m.appearance.stroke_width())).collect()
|
||||
}
|
||||
|
||||
/// Set the top fill of series 0's first mark to the color `hex` with `appearance.setItem`, and return that paint.
|
||||
fn hex_paint(s: &mut Session, id: NodeId, hex: &str) -> Paint {
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": hex})).unwrap();
|
||||
s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint()
|
||||
}
|
||||
|
||||
/// A color or weight set with `appearance.setItem` on the topmost fill or stroke of one series mark is kept by the
|
||||
/// graph: every mark of that series has it after the graph is drawn again, and undo takes it back with its edit. A row
|
||||
/// under the topmost one is not kept, and a change to one kind of row keeps nothing for the other.
|
||||
#[test]
|
||||
fn an_appearance_item_paint_on_a_series_mark_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let blue = hex_paint(&mut s, id, "#0000ff");
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None));
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.stroke, kept.stroke_width), (Some(red.clone()), Some(3.0)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (blue.clone(), red.clone(), 3.0)), "{:?}", marks_of(&s, id, 0));
|
||||
assert!(marks_of(&s, id, 1).iter().all(|m| m.0 != blue));
|
||||
// Undo goes back through the data edit, then takes the stroke paint away with its edit.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None));
|
||||
// The graph does not keep a color set on a fill under the topmost one.
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
let (lower, _) = fill_rows(&s, bar);
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue));
|
||||
}
|
||||
|
||||
/// A weight set on a stroke row, with no color, is kept by the graph with the row's paint.
|
||||
#[test]
|
||||
fn an_appearance_item_weight_on_a_series_mark_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
let row = bar.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "weight": 4})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(4.0));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.2 == 4.0), "{:?}", marks_of(&s, id, 0));
|
||||
}
|
||||
|
||||
/// A column graph with series 0 kept blue at 40% opacity: the graph, the paint, the first marks of series 0 and 1.
|
||||
fn blue_source() -> (Session, NodeId, Paint, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let blue = hex_paint(&mut s, id, "#0000ff");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
|
||||
s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap();
|
||||
(s, id, blue, from, to)
|
||||
}
|
||||
|
||||
/// After the graph is drawn again, every mark of series 1 is `blue` at 40%; undo goes back through the data edit and the
|
||||
/// command before it, which restores the paints the graph kept in `was`.
|
||||
fn series_1_is_blue_at_40_then_undo(s: &mut Session, id: NodeId, blue: &Paint, was: &[vectorcraft_doc::SeriesPaint]) {
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
for m in series(&n, 1).children().unwrap() {
|
||||
let f = m.appearance.fill().unwrap();
|
||||
assert_eq!((f.paint.clone(), f.opacity), (blue.clone(), 0.4));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// The topmost fill (`true`) or stroke (`false`) row of `mark`.
|
||||
fn top_row(s: &Session, mark: NodeId, fill: bool) -> usize {
|
||||
s.doc().unwrap().doc.node(mark).unwrap().appearance.items.iter().rposition(|it| it.is_fill() == fill).unwrap()
|
||||
}
|
||||
|
||||
/// A new column graph, the fill of series 0's marks and those marks' ids.
|
||||
fn column_marks() -> (Session, NodeId, Paint, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
(s, id, marks[0].appearance.fill_paint(), marks.iter().map(|m| m.id).collect())
|
||||
}
|
||||
|
||||
/// An appearance moved onto a mark of another series makes the graph keep its paint with its opacity for that series,
|
||||
/// and leaves the source series without paint.
|
||||
#[test]
|
||||
fn moving_an_appearance_onto_a_series_mark_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0})).unwrap();
|
||||
let paints = spec(&s, id).series_paints;
|
||||
assert_eq!((paints[0].fill.clone(), paints[1].fill.clone()), (Some(Paint::None), Some(blue.clone())));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// An appearance copied onto a mark of another series makes the graph keep its paint with its opacity for that series.
|
||||
#[test]
|
||||
fn copying_an_appearance_onto_a_series_mark_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// An Eyedropper pick onto a mark of another series makes the graph keep its paint with its opacity for that series.
|
||||
#[test]
|
||||
fn an_eyedropper_pick_onto_a_series_mark_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// A reversed Eyedropper (Alt-click) copies onto the clicked mark, and the graph keeps the paint for the clicked series.
|
||||
#[test]
|
||||
fn a_reversed_eyedropper_pick_keeps_its_paint_on_the_clicked_series() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.copyFrom", &json!({"source": to.0, "ids": [from.0], "reverse": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
|
||||
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
|
||||
}
|
||||
|
||||
/// A reversed Eyedropper with nothing selected copies what it picked up last onto the clicked mark.
|
||||
#[test]
|
||||
fn a_reversed_eyedropper_pick_of_the_last_sample_keeps_its_paint() {
|
||||
let (mut s, id, blue, from, to) = blue_source();
|
||||
s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": []})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": to.0, "reverse": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue));
|
||||
}
|
||||
|
||||
/// Clear Appearance on a series mark leaves it with no fill and no stroke, and the series stays that way when the graph
|
||||
/// is drawn again. Undo brings the kept paint back.
|
||||
#[test]
|
||||
fn clearing_a_series_marks_appearance_updates_its_kept_paint() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let row = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(Paint::None), Some(Paint::None), Some(0.0)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (Paint::None, Paint::None, 0.0)), "{:?}", marks_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// A mark with `blue` under a second fill: the graph, the paint, the mark and its (lower, upper) fill rows, with the
|
||||
/// upper row green and kept.
|
||||
fn two_fills() -> (Session, NodeId, Paint, NodeId, usize, usize) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let blue = hex_paint(&mut s, id, "#0000ff");
|
||||
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
|
||||
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
|
||||
let (lower, upper) = fill_rows(&s, bar);
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "color": "#00ff00"})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, Some(blue.clone()));
|
||||
(s, id, blue, bar, lower, upper)
|
||||
}
|
||||
|
||||
/// `cmd` makes the graph keep `blue`, and undo brings back the paints it kept before.
|
||||
fn top_row_command_keeps_blue(s: &mut Session, id: NodeId, blue: &Paint, cmd: &str, p: &serde_json::Value) {
|
||||
let was = spec(s, id).series_paints;
|
||||
s.execute(cmd, p).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints[0].fill.as_ref(), Some(blue), "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints, was, "{cmd}");
|
||||
}
|
||||
|
||||
/// Reduce to Basic Appearance with the lower fill as the active row leaves that fill, and the graph keeps its paint.
|
||||
#[test]
|
||||
fn reducing_a_series_marks_appearance_keeps_the_row_it_leaves() {
|
||||
let (mut s, id, blue, bar, lower, _) = two_fills();
|
||||
s.execute("select.set", &json!({"ids": [bar.0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": lower})).unwrap();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.reduceToBasic", &json!({}));
|
||||
}
|
||||
|
||||
/// Removing a mark's top row makes the graph keep the paint now on top.
|
||||
#[test]
|
||||
fn removing_a_series_marks_top_row_keeps_the_paint_now_on_top() {
|
||||
let (mut s, id, blue, bar, _, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.removeItem", &json!({"ids": [bar.0], "index": upper}));
|
||||
}
|
||||
|
||||
/// Moving a lower row above the others makes the graph keep its paint.
|
||||
#[test]
|
||||
fn moving_a_row_to_a_series_marks_top_keeps_its_paint() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "from": lower, "to": upper}));
|
||||
}
|
||||
|
||||
/// Moving a block of rows above the others makes the graph keep the paint now on top.
|
||||
#[test]
|
||||
fn moving_a_block_of_rows_to_a_series_marks_top_keeps_its_paint() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "indices": [lower], "to": upper}));
|
||||
}
|
||||
|
||||
/// A copy of a lower row placed on top makes the graph keep its paint.
|
||||
#[test]
|
||||
fn a_copy_placed_on_a_series_marks_top_keeps_its_paint() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.duplicateItem", &json!({"ids": [bar.0], "index": lower, "to": upper + 1}));
|
||||
}
|
||||
|
||||
/// The mark's only stroke row removed: the series draws without a stroke; a new row comes back as its paint.
|
||||
#[test]
|
||||
fn removing_and_adding_a_series_marks_only_stroke_updates_its_kept_paint() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let bar = marks[0];
|
||||
let row = top_row(&s, bar, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": row})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (None, Some(Paint::None), Some(0.0)));
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.addStroke", &json!({"ids": [bar.0]})).unwrap();
|
||||
let stroke = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint();
|
||||
assert!(!stroke.is_none());
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(stroke));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// Hiding the top row makes the graph keep the visible row under it, and its opacity; hiding the only one keeps no paint;
|
||||
/// Show All Hidden Attributes brings the top row back. A hidden row on top of a moved appearance is not kept.
|
||||
#[test]
|
||||
fn a_hidden_top_row_is_not_what_the_graph_keeps() {
|
||||
let (mut s, id, blue, bar, lower, upper) = two_fills();
|
||||
let green = s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "opacity": 50})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None, "the row under the top one");
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == blue), "{:?}", marks_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(green), None));
|
||||
|
||||
// A transfer of [blue, hidden green] onto another series keeps the blue the source shows.
|
||||
let (mut s, id, blue, bar, _, upper) = two_fills();
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
|
||||
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.transfer", &json!({"source": bar.0, "target": to.0, "copy": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue));
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "visible": false})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None));
|
||||
}
|
||||
|
||||
/// Hiding a top row and Show All Hidden Attributes each undo in one step, with what the graph stores.
|
||||
#[test]
|
||||
fn hiding_and_showing_a_series_marks_top_row_undo_with_the_stored_paint() {
|
||||
let (mut s, id, _, bar, _, upper) = two_fills();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
|
||||
let hidden = spec(&s, id).series_paints;
|
||||
assert_ne!(hidden, was);
|
||||
s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, hidden);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// An opaque Eyedropper pick onto a mark replaces the opacity another mark of the series stored, even though the mark
|
||||
/// itself stays opaque: the series is drawn with the picked color at 100%.
|
||||
#[test]
|
||||
fn an_opaque_pick_replaces_the_opacity_the_series_stores() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "color": "#0000ff"})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
|
||||
// A pick of the opacity alone replaces it too, and a pick that leaves opacity out keeps it.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let colors_only = json!({"appearance": {"fill": {"color": true, "transparency": false}, "stroke": {"color": false}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": colors_only})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
|
||||
let opacity_only = json!({"appearance": {"fill": {"color": false, "transparency": true}, "stroke": {"color": false}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": opacity_only})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
|
||||
}
|
||||
|
||||
/// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's
|
||||
/// empty one, and the series still shows when the graph is drawn again.
|
||||
#[test]
|
||||
fn moving_an_appearance_within_a_series_keeps_the_targets_paint() {
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": marks[1].0})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == shown), "{:?}", marks_of(&s, id, 0));
|
||||
}
|
||||
|
||||
/// A row shown again with the color the mark already shows replaces the paint another mark of the series stored.
|
||||
#[test]
|
||||
fn showing_a_row_with_the_color_a_mark_shows_replaces_the_kept_paint() {
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let (_, upper) = fill_rows(&s, marks[0]);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
|
||||
let other = top_row(&s, marks[1], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": other, "color": "#ff0000"})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": true, "color": "#000000"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint()));
|
||||
}
|
||||
|
||||
/// A color or weight set again to what a mark already shows and an Eyedropper pick that leaves the mark as it was replace
|
||||
/// the paint another mark of the series stored, in an undo step of their own. Clear Appearance on a clear mark leaves the
|
||||
/// stored paint and the undo history as they are.
|
||||
#[test]
|
||||
fn writing_the_paint_a_series_mark_already_shows_replaces_the_kept_paint() {
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "color": "#000000"})).unwrap();
|
||||
let again = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(again));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
for (mark, weight) in [(marks[1], 2), (marks[0], 5), (marks[1], 2)] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": stroke, "color": "#ff0000", "weight": weight})).unwrap();
|
||||
}
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "weight": 5})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 2})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
|
||||
|
||||
let (mut s, id, shown, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": marks[2].0, "ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown));
|
||||
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
let shown = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
|
||||
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), shown, "the undo took back the color, not a clear");
|
||||
}
|
||||
|
||||
/// A line graph with a marker: the graph, the first thin line, a marker.
|
||||
fn line_and_marker() -> (Session, NodeId, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id;
|
||||
let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id;
|
||||
(s, id, line, marker)
|
||||
}
|
||||
|
||||
/// The first thin line of series 0, and every other path of the series (its markers and legend swatch).
|
||||
fn line_parts(s: &Session, id: NodeId) -> (vectorcraft_doc::Node, Vec<vectorcraft_doc::Node>) {
|
||||
let parts = series_parts(s, id, 0);
|
||||
let (lines, others): (Vec<_>, Vec<_>) =
|
||||
parts.into_iter().partition(|m| m.appearance.fill_paint().is_none() && m.appearance.stroke_width() == 1.0);
|
||||
(lines[0].clone(), others)
|
||||
}
|
||||
|
||||
/// On a line graph, a stroke set on the thin line goes on the line after a redraw; a fill set on the thin line goes on
|
||||
/// the markers and the legend swatch, and the line stays without a fill.
|
||||
#[test]
|
||||
fn a_line_series_draws_its_kept_stroke_on_the_line_and_its_kept_fill_on_the_markers() {
|
||||
let (mut s, id, line, _) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(line).unwrap().appearance.fill_paint();
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let (drawn, others) = line_parts(&s, id);
|
||||
assert_eq!((drawn.appearance.fill_paint(), drawn.appearance.stroke_paint()), (Paint::None, red));
|
||||
assert!(others.iter().all(|m| m.appearance.fill_paint() == blue), "{others:?}");
|
||||
}
|
||||
|
||||
/// Clear Appearance on a thin line leaves the fill the graph stores for the markers; on a marker that shows no stroke, it
|
||||
/// leaves the stroke the graph stores for the line. An Eyedropper pick of a stroke-only source onto the line and a copy
|
||||
/// of one line onto another leave the stored fill too.
|
||||
#[test]
|
||||
fn a_row_left_without_paint_leaves_the_paint_the_series_stores() {
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = spec(&s, id).series_paints[0].fill.clone().unwrap();
|
||||
s.execute("appearance.clear", &json!({"ids": [line.0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill.as_ref(), kept.stroke.as_ref()), (Some(&blue), Some(&Paint::None)));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let parts = series_parts(&s, id, 0);
|
||||
assert!(parts.iter().filter(|m| !m.appearance.fill_paint().is_none()).all(|m| m.appearance.fill_paint() == blue));
|
||||
assert!(parts.iter().any(|m| m.appearance.fill_paint() == blue), "the markers keep their fill");
|
||||
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
|
||||
s.execute("appearance.clear", &json!({"ids": [marker.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()), "the marker showed no stroke");
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red);
|
||||
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = spec(&s, id).series_paints[0].fill.clone().unwrap();
|
||||
let other = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.copyFrom", &json!({"source": other.0, "ids": [line.0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue.clone()), "the source's line has no fill");
|
||||
s.execute("appearance.transfer", &json!({"source": other.0, "target": line.0, "copy": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue));
|
||||
}
|
||||
|
||||
/// A line graph with Filled Lines on: the session, the graph and series 0's band.
|
||||
fn band_graph() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2");
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
|
||||
let band = series_parts(&s, id, 0)[0].id;
|
||||
(s, id, band)
|
||||
}
|
||||
|
||||
/// The band's fill, outline paint and outline weight after the graph is drawn again.
|
||||
fn redrawn_band(s: &mut Session, id: NodeId) -> (Paint, Paint, f64) {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [4]]})).unwrap();
|
||||
let band = series_parts(s, id, 0)[0].clone();
|
||||
(band.appearance.fill_paint(), band.appearance.stroke_paint(), band.appearance.stroke_width())
|
||||
}
|
||||
|
||||
/// A fill set on a Filled Lines band is kept, and the band keeps its thin black outline.
|
||||
#[test]
|
||||
fn a_filled_line_band_keeps_a_fill_set_on_it() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": top_row(&s, band, true), "color": "#0000ff"})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint();
|
||||
assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
}
|
||||
|
||||
/// A stroke set on a Filled Lines band is not kept: the band keeps its color and its thin black outline.
|
||||
#[test]
|
||||
fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
let row = top_row(&s, band, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
|
||||
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
}
|
||||
|
||||
/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black
|
||||
/// outline when the graph is drawn again.
|
||||
#[test]
|
||||
fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let row = top_row(&s, band, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap();
|
||||
let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default();
|
||||
assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3)));
|
||||
let (_, outline, width) = redrawn_band(&mut s, id);
|
||||
assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
let band = series_parts(&s, id, 0)[0].clone();
|
||||
assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3);
|
||||
}
|
||||
|
||||
/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the
|
||||
/// band its color: an empty stored stroke is not a color for the band.
|
||||
#[test]
|
||||
fn removing_a_filled_line_bands_stroke_leaves_its_color() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [band.0], "index": top_row(&s, band, false)})).unwrap();
|
||||
assert_eq!(redrawn_band(&mut s, id).0, was);
|
||||
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
|
||||
assert_eq!(redrawn_band(&mut s, id).0, was);
|
||||
}
|
||||
|
||||
/// With Connect Data Points off a Filled Lines graph draws no bands, so a stroke set on a marker is kept.
|
||||
#[test]
|
||||
fn a_stroke_on_a_marker_is_kept_when_filled_lines_draws_no_bands() {
|
||||
let (mut s, id, _) = band_graph();
|
||||
s.execute("graph.setType", &json!({"id": id.0, "connectPoints": false})).unwrap();
|
||||
let marker = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "color": "#ff0000"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none()));
|
||||
}
|
||||
|
||||
/// A Filled Lines graph of `ty` and `csv` draws no bands, so a stroke set on its first mark is stored and stays on it after
|
||||
/// the graph is drawn again.
|
||||
fn stroke_is_kept_without_bands(ty: &str, csv: &str) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, ty, csv);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
|
||||
let mark = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(mark).unwrap().appearance.stroke_paint();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let drawn = &series_parts(&s, id, 0)[0];
|
||||
assert_eq!((drawn.appearance.stroke_paint(), drawn.appearance.stroke_width()), (red, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_kept_on_a_one_row_line_series() {
|
||||
stroke_is_kept_without_bands("line", ",a\nQ1,2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_kept_on_a_radar_series_of_zeros() {
|
||||
stroke_is_kept_without_bands("radar", ",a\nQ1,0\nQ2,0\nQ3,0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_kept_on_a_scatter_series_of_one_repeated_point() {
|
||||
stroke_is_kept_without_bands("scatter", "a,b\n1,2\n1,2");
|
||||
}
|
||||
|
||||
/// A radar series whose values all fall below `axisMin` is plotted at the center and draws no band, so a stroke set on its
|
||||
/// marks is kept. A radar series with a value above `axisMin` draws a band and stores no stroke.
|
||||
#[test]
|
||||
fn a_radar_series_draws_a_band_only_for_values_away_from_the_center() {
|
||||
for (csv, kept) in [(",a\nQ1,1\nQ2,2\nQ3,3", true), (",a\nQ1,1\nQ2,15\nQ3,3", false)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, "radar", csv);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "axisMin": 10, "axisMax": 20})).unwrap();
|
||||
let mark = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints.first().is_some_and(|p| p.stroke.is_some()), kept, "{csv}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the Filled Lines budget the graph draws thin lines, and a stroke set on one is kept.
|
||||
#[test]
|
||||
fn a_stroke_on_a_thin_line_past_the_filled_lines_budget_is_kept() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let rows: Vec<Vec<f64>> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect();
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "markPoints": false})).unwrap();
|
||||
let line = series_parts(&s, id, 0)[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none()));
|
||||
}
|
||||
|
||||
/// A weight of 0 set again on a painted stroke replaces the weight another mark of the series left on the graph.
|
||||
#[test]
|
||||
fn a_zero_weight_on_a_painted_stroke_replaces_the_kept_weight() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 0})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 0})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(0.0));
|
||||
}
|
||||
|
||||
/// An Eyedropper `append` of a source with no stroke leaves the stroke the series keeps.
|
||||
#[test]
|
||||
fn appending_a_fill_only_appearance_leaves_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, false)})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
|
||||
// A pick of the weight alone from that source leaves the mark and what the series keeps as they were.
|
||||
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
|
||||
}
|
||||
|
||||
/// An Eyedropper `append` of a source with only a hidden fill leaves the fill the series keeps.
|
||||
#[test]
|
||||
fn appending_a_hidden_fill_leaves_the_kept_fill() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].fill.clone();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "visible": false})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, kept);
|
||||
}
|
||||
|
||||
/// An Eyedropper pick of the weight alone reads a hidden stroke too, and that weight replaces the one the series keeps.
|
||||
#[test]
|
||||
fn picking_the_weight_of_a_hidden_stroke_replaces_the_kept_weight() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let row = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 2, "visible": false})).unwrap();
|
||||
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
|
||||
}
|
||||
|
||||
/// An Eyedropper pick of some attributes edits the mark's topmost stroke. When that row is hidden, the series keeps what
|
||||
/// it stores instead of taking the visible row under it.
|
||||
#[test]
|
||||
fn a_partial_pick_onto_a_hidden_top_row_leaves_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
let hidden = top_row(&s, marks[1], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": hidden, "visible": false})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let row = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 3})).unwrap();
|
||||
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": true, "weight": true}}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept);
|
||||
}
|
||||
|
||||
/// An Eyedropper `append` writes a visible row under the source's hidden top row, and the series stores that row's paint
|
||||
/// even when the mark already shows the same paint.
|
||||
#[test]
|
||||
fn appending_a_visible_row_under_a_hidden_one_replaces_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
|
||||
let row = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({"ids": [source.0]})).unwrap();
|
||||
let hidden = top_row(&s, source, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": hidden, "visible": false})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
|
||||
let kept = &spec(&s, id).series_paints[0];
|
||||
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
|
||||
assert_eq!(kept.stroke_width, Some(2.0));
|
||||
}
|
||||
|
||||
/// A stroke added on top of a mark replaces the stroke another mark of the series left on the graph, even when the new
|
||||
/// row shows the paint the mark already showed.
|
||||
#[test]
|
||||
fn adding_a_stroke_replaces_the_kept_stroke_with_the_marks_own() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
let kept = &spec(&s, id).series_paints[0];
|
||||
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
|
||||
assert_eq!(kept.stroke_width, Some(2.0));
|
||||
}
|
||||
|
||||
/// A fill added right above a mark's active top fill replaces the fill another mark of the series left on the graph.
|
||||
#[test]
|
||||
fn adding_a_fill_above_the_active_top_fill_replaces_the_kept_fill() {
|
||||
let (mut s, id, was, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#0000ff"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": fill})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(was));
|
||||
}
|
||||
|
||||
/// A copy of a mark's top stroke replaces the stroke another mark of the series left on the graph.
|
||||
#[test]
|
||||
fn duplicating_a_marks_top_stroke_replaces_the_kept_stroke() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
for to in [false, true] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
|
||||
let top = top_row(&s, marks[1], false);
|
||||
let p = if to { json!({"ids": [marks[1].0], "index": top, "to": top + 1}) } else { json!({"ids": [marks[1].0], "index": top}) };
|
||||
s.execute("appearance.duplicateItem", &p).unwrap();
|
||||
let kept = &spec(&s, id).series_paints[0];
|
||||
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))), "{p}");
|
||||
assert_eq!(kept.stroke_width, Some(2.0), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A weight set on a stroke without paint (a line graph's marker) leaves the paint the series keeps for its line.
|
||||
#[test]
|
||||
fn a_weight_on_a_stroke_without_paint_leaves_the_kept_stroke() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let marks = series(&n, 0).children().unwrap().to_vec();
|
||||
let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id;
|
||||
let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id;
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(kept.stroke.as_ref().is_some_and(|p| !p.is_none()));
|
||||
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "weight": 4})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept);
|
||||
}
|
||||
|
||||
/// The graph does not keep a paint set on a row of a graph design's copy inside a series. A stroke edit on a line keeps
|
||||
/// no fill, and a blend mode alone leaves what the graph keeps as it was.
|
||||
#[test]
|
||||
fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
save_design(&mut s, "Square");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
|
||||
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
assert!(matches!(copy.kind, NodeKind::Group { .. }));
|
||||
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
|
||||
// A path inside the design's copy is a member of the series too, and its paint is not kept either.
|
||||
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
|
||||
let mut paths = vec![];
|
||||
copy.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) {
|
||||
paths.push(n.id);
|
||||
}
|
||||
});
|
||||
let art = paths[0];
|
||||
s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let line = series(&n, 0).children().unwrap().iter().find(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).unwrap().clone();
|
||||
let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap();
|
||||
assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill");
|
||||
|
||||
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let fill = top_row(&s, marks[0], true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
|
||||
let kept = spec(&s, id);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap();
|
||||
assert_eq!(spec(&s, id), kept);
|
||||
}
|
||||
|
||||
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
|
||||
#[test]
|
||||
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
|
||||
|
||||
@@ -137,6 +137,13 @@ pub struct FrameStats {
|
||||
/// The filtered contents, shadows and glows the frame drew at a drag's lower resolution
|
||||
/// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too.
|
||||
pub previewed: usize,
|
||||
/// What the frame drew in a way that depends on the frame's size: effect rasters and filter
|
||||
/// layers cropped to it or made at a lower resolution, blending groups drawn as layers past its
|
||||
/// offscreen budget, gradient meshes drawn quad by quad past the mesh buffer, and every filter
|
||||
/// layer drawn on a single-threaded context: on a single-threaded renderer, or offscreen in a
|
||||
/// group or an effect's raster. A part of the frame rendered on its own can draw that art with
|
||||
/// other pixels than the whole frame does.
|
||||
pub view_sized: usize,
|
||||
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
|
||||
/// transparency; 0 for other frames.
|
||||
pub ink_threads: usize,
|
||||
|
||||
@@ -100,6 +100,7 @@ impl Renderer {
|
||||
// The cached raster: of the whole content at full size at this zoom, else of this view (and
|
||||
// the margin around it).
|
||||
let whole = fits && k >= 1.0;
|
||||
self.stats.view_sized += usize::from(!whole);
|
||||
let in_view = (!whole).then(|| ViewCrop::of(f.view, room));
|
||||
let stamp = self.stamp;
|
||||
let channels = match f.ink.base() {
|
||||
@@ -574,10 +575,15 @@ impl Renderer {
|
||||
composite: Option<(vectorcraft_doc::color::BlendMode, f32)>,
|
||||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>),
|
||||
) {
|
||||
// A filter layer's coarser grid starts where its context does: at the corner of a context
|
||||
// drawn single-threaded, and at the frame's edge for a region cropped to the frame.
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));
|
||||
self.stats.view_sized += usize::from(!f.mt || f.view.transform_rect_bbox(reach).union(screen) != screen);
|
||||
if !f.mt {
|
||||
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
||||
}
|
||||
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
|
||||
self.stats.view_sized += usize::from(k < 1.0);
|
||||
// During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558).
|
||||
let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET);
|
||||
let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) {
|
||||
|
||||
@@ -183,6 +183,7 @@ impl Renderer {
|
||||
(image, x0, y0, scale, moved, cropped)
|
||||
}
|
||||
};
|
||||
self.stats.view_sized += usize::from(cropped.is_some() || scale != 1.0);
|
||||
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped })
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ impl Renderer {
|
||||
if c.mask.is_some() || self.offscreen_fits(ctx, r, c.isolated) {
|
||||
return self.group_offscreen(ctx, f, c, r, draw);
|
||||
}
|
||||
self.stats.view_sized += 1;
|
||||
}
|
||||
if c.mask.is_some() {
|
||||
// Its backdrop out of reach (or not needed), a masked group starts from transparency.
|
||||
|
||||
@@ -582,6 +582,7 @@ impl Renderer {
|
||||
ctx.set_transform(f.view);
|
||||
return;
|
||||
}
|
||||
self.stats.view_sized += 1;
|
||||
ctx.set_transform(f.view);
|
||||
// Where a patch narrows to slivers, many quads share each pixel, each covering a little of it: their edges
|
||||
// composite to part coverage, and hard edges drop each one. An opaque patch is backed by its own outline in
|
||||
|
||||
@@ -498,4 +498,13 @@ fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() {
|
||||
assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}");
|
||||
assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups");
|
||||
assert_near(frame.pixel(10, 10), B, "outside the groups");
|
||||
let mut r = Renderer::new();
|
||||
render_with(&mut r, &d, &RenderOptions::default());
|
||||
assert!(r.stats.view_sized > 0, "levels past the budget depend on the frame's size");
|
||||
let within = doc(|d| {
|
||||
let n = multiply(d);
|
||||
group(d, vec![n], 1.0, Blend::Multiply, true)
|
||||
});
|
||||
render_with(&mut r, &within, &RenderOptions::default());
|
||||
assert_eq!(r.stats.view_sized, 0, "a group within the budget");
|
||||
}
|
||||
|
||||
@@ -180,6 +180,72 @@ fn pans_within_a_cropped_rasters_margin_draw_it_kept() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame counts the art it draws in a way that depends on its size: an effect raster cropped to
|
||||
/// it or made at a lower resolution, drawn from the raster kept as well as made; a filter layer
|
||||
/// cropped to it or made at a lower resolution; any filter layer drawn on a single-threaded context,
|
||||
/// as on a single-threaded renderer or in a group drawn offscreen; and a mesh past the mesh buffer.
|
||||
/// Art with a raster of its own whole, and a filter layer that stays inside the frame, are not
|
||||
/// counted.
|
||||
#[test]
|
||||
fn a_frame_counts_the_art_it_draws_by_its_size() {
|
||||
let effect = |id: &str, params: serde_json::Value| backdrop(&[(id, params)]).0;
|
||||
let mesh = |side: f64| {
|
||||
let mut d = Document::new(595.0, 842.0);
|
||||
let m = vectorcraft_doc::GradientMesh::from_surface(2, 2, &|u, v| vectorcraft_geom::Point::new(u * side, v * side), &|u, v| {
|
||||
Color::rgb(u as f32, v as f32, 0.5)
|
||||
});
|
||||
let layer = d.layers[0].id;
|
||||
let node = Node::new(d.alloc_id(), NodeKind::Mesh(m));
|
||||
d.insert(Some(layer), 0, node).unwrap();
|
||||
d
|
||||
};
|
||||
let blur = effect("blur.gaussian", json!({"radius": 8}));
|
||||
let shadow = effect("stylize.dropShadow", json!({"blur": 8, "x": 4, "y": 4}));
|
||||
let outer_glow = effect("stylize.outerGlow", json!({"blur": 8}));
|
||||
let glow = effect("stylize.innerGlow", json!({"blur": 8}));
|
||||
let wide_glow = effect("stylize.innerGlow", json!({"blur": 2000}));
|
||||
let group_glow = {
|
||||
let mut d = Document::new(595.0, 842.0);
|
||||
let basic = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
|
||||
let wide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)), basic(Color::rgb(0.9, 0.8, 0.3)));
|
||||
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 200.0, 330.0, 500.0)), basic(Color::rgb(0.1, 0.3, 0.9)));
|
||||
child.appearance.effects = vec![Effect { id: "stylize.innerGlow".into(), params: json!({"blur": 40}), visible: true }];
|
||||
child.blend = vectorcraft_doc::color::BlendMode::Multiply;
|
||||
let mut group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(wide), std::sync::Arc::new(child)]);
|
||||
group.isolate = true;
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, group).unwrap();
|
||||
d
|
||||
};
|
||||
let cases = [
|
||||
("a whole blur raster", &blur, 1.0, (640, 900), 2, false),
|
||||
("a blur raster cropped to the frame", &blur, 10.0, (256, 128), 2, true),
|
||||
("a blur raster at a lower resolution", &blur, 5.0, (3200, 4400), 2, true),
|
||||
("a whole shadow raster", &shadow, 1.0, (640, 900), 2, false),
|
||||
("a shadow raster cropped to the frame", &shadow, 40.0, (256, 128), 2, true),
|
||||
("an outer glow raster at a lower resolution", &outer_glow, 5.0, (3200, 4400), 2, true),
|
||||
("a filter layer inside the frame", &glow, 1.0, (640, 900), 2, false),
|
||||
("a filter layer past the frame's edge", &glow, 1.0, (256, 128), 2, true),
|
||||
("a filter layer at a lower resolution", &wide_glow, 1.0, (640, 900), 2, true),
|
||||
("a filter layer on a single-threaded renderer", &glow, 1.0, (640, 900), 0, true),
|
||||
("a filter layer in a group drawn offscreen", &group_glow, 1.0, (640, 900), 2, true),
|
||||
];
|
||||
for (name, d, zoom, size, threads, counted) in cases {
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
r.render(d, size.0, size.1, view(zoom, size), &opts);
|
||||
assert_eq!(r.stats.view_sized > 0, counted, "{name}");
|
||||
r.render(d, size.0, size.1, view(zoom, size), &opts);
|
||||
assert_eq!(r.stats.view_sized > 0, counted, "{name}, drawn again");
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
r.render(&mesh(100.0), 2100, 2100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert_eq!(r.stats.view_sized, 0, "a mesh within the mesh buffer");
|
||||
r.render(&mesh(3000.0), 2100, 2100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert!(r.stats.view_sized > 0, "a mesh past the mesh buffer");
|
||||
}
|
||||
|
||||
/// Scrolls by amounts that are not whole pixels, as the wheel and a trackpad give them, moved in whole pixels with the part left
|
||||
/// over carried to the next, as the canvas moves them: after the scroll that makes the Drop Shadow's cropped raster
|
||||
/// again with its margin, every later scroll draws it kept. The same scrolls moved as given make it again in every
|
||||
|
||||
@@ -70,6 +70,7 @@ impl VectorcraftApp {
|
||||
last_ms: 0.0,
|
||||
moved: false,
|
||||
from_worker: false,
|
||||
reused: None,
|
||||
waits_for_still: cfg!(target_arch = "wasm32"),
|
||||
still: None,
|
||||
fill: None,
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct CanvasCache {
|
||||
/// The frame shown was rendered on the worker: an interaction's frames stay there
|
||||
/// ([`canvas::show`]).
|
||||
pub from_worker: bool,
|
||||
/// The frame shown holds pixels the worker copied from its last frame, and when it was shown
|
||||
/// (seconds, egui's input time): it renders whole once it has been shown for
|
||||
/// [`canvas::STILL_SECS`] with the view unmoved.
|
||||
pub reused: Option<f64>,
|
||||
/// Set in the web build, which has no render worker: while the view changes on a document whose
|
||||
/// last render took more than 8 ms, the canvas draws the frame shown reprojected and renders
|
||||
/// once the view has been still for [`canvas::STILL_SECS`].
|
||||
|
||||
@@ -65,7 +65,7 @@ use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight,
|
||||
pub use panes::Panes;
|
||||
pub(crate) use panes::{consolidate, pane, tile, tiled_area};
|
||||
use raster::{changed_pixels, pixel_region, upload};
|
||||
pub(crate) use raster::{color_image, color_image_owned};
|
||||
pub(crate) use raster::{color_image, color_image_owned, render_job};
|
||||
pub use selection_marks::SelectionMarks;
|
||||
use task_bar::task_bar;
|
||||
pub(crate) use task_bar::{has_colors, task_bar_rect};
|
||||
@@ -297,7 +297,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if let Some(secs) = wait {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs));
|
||||
}
|
||||
if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) {
|
||||
// A frame with copied pixels renders whole once it has been shown for STILL_SECS with the view unmoved.
|
||||
let same_frame = app.canvas.key.as_ref() == Some(&key);
|
||||
let settle = app.canvas.reused.filter(|_| same_frame).is_some_and(|at| {
|
||||
let waited = ui.input(|i| i.time) - at;
|
||||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||||
let waiting = (0.0..STILL_SECS).contains(&waited);
|
||||
if waiting {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(STILL_SECS - waited));
|
||||
}
|
||||
!waiting
|
||||
});
|
||||
if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) {
|
||||
let (w, h, view) = frame_view(pixel, (w, h), &v, ppp);
|
||||
let opts = frame_options(app, &key, mask_view, smooth_images);
|
||||
draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel });
|
||||
@@ -571,7 +582,11 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate:
|
||||
app.canvas.key = Some(done.key);
|
||||
app.canvas.shown = Some((done.doc, done.opts));
|
||||
app.perf.render_ms = done.ms;
|
||||
app.canvas.last_ms = done.ms;
|
||||
// A frame with copied pixels leaves `last_ms` at the whole frame's.
|
||||
if !done.reused {
|
||||
app.canvas.last_ms = done.ms;
|
||||
}
|
||||
app.canvas.reused = done.reused.then(|| ctx.input(|i| i.time));
|
||||
app.canvas.moved = done.moved > 0;
|
||||
app.canvas.from_worker = true;
|
||||
}
|
||||
@@ -671,6 +686,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
let first = app.canvas.texture.is_none() || !same_doc;
|
||||
let heavy = first || app.canvas.last_ms > 8.0 || settling || staying;
|
||||
let changed = changed_pixels(app, key, doc, &opts, view, w, h);
|
||||
// A new view can copy from the worker's last frame. The frame shown asked for again renders
|
||||
// whole. The worker keeps either one for the next pan to copy from.
|
||||
let may_copy = app.canvas.key.as_ref() != Some(key);
|
||||
let mut landed = None;
|
||||
match (&mut app.canvas.worker, heavy, changed) {
|
||||
// Only the document changed since the frame shown, in a part of it: that part is
|
||||
@@ -697,6 +715,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
view,
|
||||
opts,
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
may_copy,
|
||||
may_keep: true,
|
||||
});
|
||||
// A light document's first frame is ready within the grace and shows in this frame,
|
||||
// with no frame of bare paper. Waited for once, when the frame is queued.
|
||||
@@ -714,6 +734,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
app.canvas.last_ms = app.perf.render_ms;
|
||||
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
|
||||
app.canvas.from_worker = false;
|
||||
app.canvas.reused = None;
|
||||
}
|
||||
}
|
||||
if let Some(done) = landed {
|
||||
|
||||
@@ -106,6 +106,8 @@ pub(super) fn fill_uncovered(
|
||||
view,
|
||||
opts,
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
may_copy: false,
|
||||
may_keep: false,
|
||||
});
|
||||
}
|
||||
let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return };
|
||||
|
||||
@@ -82,6 +82,153 @@ fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
|
||||
(a, a + len)
|
||||
}
|
||||
|
||||
/// The most bytes of the last frame the render worker keeps: 16 million pixels. A larger frame is
|
||||
/// not kept, and every frame after it renders whole.
|
||||
const MAX_KEPT_BYTES: usize = 64 << 20;
|
||||
|
||||
/// How far the view of a frame may be from a whole device pixel, in device pixels, for the frame's
|
||||
/// pixels to be copied.
|
||||
const WHOLE_PIXEL: f64 = 1e-6;
|
||||
|
||||
/// The render worker's last frame, which its next frame can copy pixels from when it holds them.
|
||||
pub(crate) struct KeptFrame {
|
||||
key: CacheKey,
|
||||
opts: vectorcraft_render::RenderOptions,
|
||||
view: Affine,
|
||||
pub(crate) pixels: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A frame the render worker finished.
|
||||
pub(crate) struct WorkerFrame {
|
||||
pub(crate) img: vectorcraft_render::Rendered,
|
||||
/// [`vectorcraft_render::FrameStats::moved`] of the renders that made the frame.
|
||||
pub(crate) moved: usize,
|
||||
/// The frame holds pixels copied from the last frame.
|
||||
pub(crate) reused: bool,
|
||||
}
|
||||
|
||||
/// Nothing but the position of the view differs between the frame `kept` and the job `job`, in
|
||||
/// their keys, render options and views, and the frame is not Pixel Preview's and shows no opacity
|
||||
/// mask.
|
||||
fn only_position_differs(kept: &KeptFrame, job: &crate::render_worker::Job) -> bool {
|
||||
let (a, b) = (&kept.key, &job.key);
|
||||
let same_scale = a.zoom == b.zoom && a.rot == b.rot && a.ppp == b.ppp && (a.w, a.h) == (b.w, b.h) && (job.w, job.h) == (b.w, b.h);
|
||||
same_scale
|
||||
&& super::cursor::only_view_differs(a, b)
|
||||
&& a.pixel.is_none()
|
||||
&& b.pixel.is_none()
|
||||
&& kept.opts == job.opts
|
||||
&& job.opts.mask_view.is_none()
|
||||
&& kept.view.as_coeffs()[..4] == job.view.as_coeffs()[..4]
|
||||
}
|
||||
|
||||
/// How many whole device pixels the art moves between the frame `kept` and the job `job`, when
|
||||
/// `job` can copy pixels from it: `job` allows it, nothing but the position of the view differs
|
||||
/// ([`only_position_differs`]), and the art moves by whole device pixels.
|
||||
fn whole_pixel_pan(kept: &KeptFrame, job: &crate::render_worker::Job) -> Option<(i64, i64)> {
|
||||
if !job.may_copy || !only_position_differs(kept, job) {
|
||||
return None;
|
||||
}
|
||||
let (from, to) = (kept.view.as_coeffs(), job.view.as_coeffs());
|
||||
let whole = |d: f64| (d.is_finite() && (d - d.round()).abs() <= WHOLE_PIXEL && d.abs() < f64::from(u16::MAX)).then_some(d.round() as i64);
|
||||
Some((whole(to[4] - from[4])?, whole(to[5] - from[5])?))
|
||||
}
|
||||
|
||||
/// The parts `[x0, y0, x1, y1]` of a `w`×`h` frame that a pan by `(dx, dy)` device pixels exposes,
|
||||
/// grown to whole eighths of the frame. None when they cover more than half of it.
|
||||
fn exposed_parts(w: u32, h: u32, (dx, dy): (i64, i64)) -> Option<Vec<[u32; 4]>> {
|
||||
// How much of 0..n a move by `d` exposes, at the start for a positive `d`, else at the end.
|
||||
let span = |d: i64, n: u32| {
|
||||
let len = u32::try_from(d.unsigned_abs()).ok()?.min(n);
|
||||
Some(in_steps(if d > 0 { 0 } else { n - len }, if d > 0 { len } else { n }, n))
|
||||
};
|
||||
let mut parts = Vec::with_capacity(2);
|
||||
if dx != 0 {
|
||||
let (x0, x1) = span(dx, w)?;
|
||||
parts.push([x0, 0, x1, h]);
|
||||
}
|
||||
if dy != 0 {
|
||||
let (y0, y1) = span(dy, h)?;
|
||||
parts.push([0, y0, w, y1]);
|
||||
}
|
||||
let area: u64 = parts.iter().map(|[x0, y0, x1, y1]| u64::from(x1 - x0) * u64::from(y1 - y0)).sum();
|
||||
(area * 2 <= u64::from(w) * u64::from(h)).then_some(parts)
|
||||
}
|
||||
|
||||
/// `pixels` of a `w`×`h` frame with the art moved by `(dx, dy)` device pixels. The pixels the art
|
||||
/// does not reach stay transparent.
|
||||
fn moved_pixels(pixels: &[u8], w: u32, h: u32, (dx, dy): (i64, i64)) -> Vec<u8> {
|
||||
let (w, h) = (i64::from(w), i64::from(h));
|
||||
let mut out = vec![0; pixels.len()];
|
||||
let (x0, x1) = (dx.max(0), (w + dx).min(w));
|
||||
if x1 > x0 {
|
||||
for y in dy.max(0)..(h + dy).min(h) {
|
||||
let (from, to, n) = (((y - dy) * w + x0 - dx) as usize * 4, (y * w + x0) as usize * 4, (x1 - x0) as usize * 4);
|
||||
out[to..to + n].copy_from_slice(&pixels[from..from + n]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The frame of `job` from `pixels`, the last frame's moved by the pan, and the exposed `parts`
|
||||
/// rendered on their own, with its [`vectorcraft_render::FrameStats::moved`]. None when a part draws
|
||||
/// art in a way that depends on the frame's size ([`vectorcraft_render::FrameStats::view_sized`]).
|
||||
fn composed_frame(
|
||||
r: &mut vectorcraft_render::Renderer,
|
||||
mut pixels: Vec<u8>,
|
||||
parts: Vec<[u32; 4]>,
|
||||
job: &crate::render_worker::Job,
|
||||
) -> Option<(vectorcraft_render::Rendered, usize)> {
|
||||
let mut moved = 0;
|
||||
let stride = job.w as usize * 4;
|
||||
for [x0, y0, x1, y1] in parts {
|
||||
let view = Affine::translate((-f64::from(x0), -f64::from(y0))) * job.view;
|
||||
let part = r.render(&job.doc, x1 - x0, y1 - y0, view, &job.opts);
|
||||
if r.stats.view_sized > 0 {
|
||||
return None;
|
||||
}
|
||||
moved += r.stats.moved;
|
||||
let row = (x1 - x0) as usize * 4;
|
||||
for (i, line) in part.pixels.chunks_exact(row).enumerate() {
|
||||
let to = (y0 as usize + i) * stride + x0 as usize * 4;
|
||||
pixels[to..to + row].copy_from_slice(line);
|
||||
}
|
||||
}
|
||||
Some((vectorcraft_render::Rendered { width: job.w, height: job.h, pixels }, moved))
|
||||
}
|
||||
|
||||
/// Render the frame of `job`. A pan by whole device pixels copies the pixels that stay on screen
|
||||
/// from `kept`, the frame before it, and renders only the parts it exposes. Any other frame renders
|
||||
/// whole, and so does a pan when a part it exposes draws art in a way that depends on the frame's
|
||||
/// size ([`vectorcraft_render::FrameStats::view_sized`]). A copied frame can differ from the same
|
||||
/// frame rendered whole (by at most one level in the tests), so the canvas renders a frame whole
|
||||
/// once it has been shown for [`super::STILL_SECS`] with the view unmoved
|
||||
/// ([`crate::CanvasCache::reused`]). `kept` becomes this frame when `job` allows keeping it, it is
|
||||
/// at most [`MAX_KEPT_BYTES`], it draws no raster from before its object moved, and it draws nothing
|
||||
/// in a way that depends on its size. That whole frame is kept too, so the first step of the next
|
||||
/// pan copies from it. A kept frame holds its pixels only when the worker kept no frame before it or
|
||||
/// nothing but the position of the view differs from that frame. A frame after a zoom or an edit
|
||||
/// holds none, so it costs no copy, and the first pan step after it renders whole.
|
||||
pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option<KeptFrame>, job: &crate::render_worker::Job) -> WorkerFrame {
|
||||
let holds_pixels = kept.as_ref().is_none_or(|k| only_position_differs(k, job));
|
||||
let copied = kept.as_ref().and_then(|k| {
|
||||
let pan = whole_pixel_pan(k, job)?;
|
||||
let parts = exposed_parts(job.w, job.h, pan)?;
|
||||
composed_frame(r, moved_pixels(k.pixels.as_ref()?, job.w, job.h, pan), parts, job)
|
||||
});
|
||||
let reused = copied.is_some();
|
||||
let (img, moved) = copied.unwrap_or_else(|| {
|
||||
let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let moved = r.stats.moved;
|
||||
(img, moved)
|
||||
});
|
||||
let keeps =
|
||||
job.may_keep && moved == 0 && r.stats.view_sized == 0 && img.pixels.len() <= MAX_KEPT_BYTES && (img.width, img.height) == (job.w, job.h);
|
||||
*kept =
|
||||
keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) });
|
||||
WorkerFrame { img, moved, reused }
|
||||
}
|
||||
|
||||
/// Upload the art's raster directly into the canvas texture without reallocating or copying pixels;
|
||||
/// Pixel Preview's document pixels keep hard edges when magnified.
|
||||
pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: vectorcraft_render::Rendered, pixel: bool) {
|
||||
|
||||
@@ -2438,7 +2438,7 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() {
|
||||
// Deliver the actual replacement and verify that it replaces the cached key.
|
||||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let expected = job.key.clone();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0, reused: false }).unwrap();
|
||||
frame(&mut app, size, new_ppp);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
|
||||
}
|
||||
@@ -2581,11 +2581,12 @@ fn pan_fills_the_area_the_last_frame_leaves() {
|
||||
let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker");
|
||||
let (fw, fh, _) = fill_view(&job.key);
|
||||
assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h));
|
||||
assert!(!fill_job.may_copy && !fill_job.may_keep, "the fill worker copies nothing and holds no frame");
|
||||
assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives");
|
||||
// The fill arrives; the next frame draws it, and asks for no other fill.
|
||||
let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts);
|
||||
let (doc, opts) = (fill_job.doc, fill_job.opts);
|
||||
fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap();
|
||||
fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0, reused: false }).unwrap();
|
||||
let (meshes, pixels) = frame(&mut app);
|
||||
assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas");
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown");
|
||||
@@ -2630,11 +2631,11 @@ fn pan_fills_the_area_the_last_frame_leaves() {
|
||||
app.view_mut().unwrap().center = Point::new(250.0, 150.0);
|
||||
// That fill finishes after the worker's frame: it is taken and not kept.
|
||||
let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts);
|
||||
fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap();
|
||||
fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0, reused: false }).unwrap();
|
||||
// The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept.
|
||||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let expected = job.key.clone();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0 }).unwrap();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0, reused: false }).unwrap();
|
||||
let (meshes, _) = frame(&mut app);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown");
|
||||
assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped");
|
||||
@@ -9055,6 +9056,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img5,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9077,6 +9079,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img3,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9098,6 +9101,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img8,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9119,6 +9123,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img10,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9206,7 +9211,493 @@ fn the_contour_bar_sets_corners_and_bakes() {
|
||||
/// A frame the render worker finished for `job`: white pixels of the job's size.
|
||||
fn finished(job: &crate::render_worker::Job) -> crate::render_worker::Done {
|
||||
let img = vectorcraft_render::Rendered { width: job.w, height: job.h, pixels: vec![255; job.w as usize * job.h as usize * 4] };
|
||||
crate::render_worker::Done { key: job.key.clone(), doc: job.doc.clone(), opts: job.opts.clone(), img, ms: 40.0, moved: 0 }
|
||||
crate::render_worker::Done { key: job.key.clone(), doc: job.doc.clone(), opts: job.opts.clone(), img, ms: 40.0, moved: 0, reused: false }
|
||||
}
|
||||
|
||||
/// A job for the render worker to pan over a document of `doc`: a view at 100% moved by `(dx, dy)`
|
||||
/// device pixels, in a 256×128 frame.
|
||||
fn pan_job(doc: &std::sync::Arc<vectorcraft_doc::Document>, key: &CacheKey, (dx, dy): (f64, f64)) -> crate::render_worker::Job {
|
||||
crate::render_worker::Job {
|
||||
key: CacheKey { cx: 128.0 - dx, cy: 64.0 - dy, w: 256, h: 128, ..key.clone() },
|
||||
doc: doc.clone(),
|
||||
w: 256,
|
||||
h: 128,
|
||||
view: Affine::translate((dx, dy)),
|
||||
opts: vectorcraft_render::RenderOptions::default(),
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
may_copy: true,
|
||||
may_keep: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The document and the canvas key the render worker tests start from: rectangles on whole pixels.
|
||||
fn pan_doc() -> (std::sync::Arc<vectorcraft_doc::Document>, CacheKey) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap();
|
||||
for (x, color) in [(16, "#c02040"), (96, "#2040c0"), (176, "#20a040")] {
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": 16, "width": 64, "height": 96})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": color})).unwrap();
|
||||
}
|
||||
app.canvas.worker_started = true;
|
||||
shapes(&mut app, &egui::Context::default());
|
||||
let key = app.canvas.key.clone().expect("a frame was rendered");
|
||||
(app.session.active().unwrap().doc.clone(), CacheKey { zoom: 1.0, rot: 0.0, ppp: 1.0, pixel: None, ..key })
|
||||
}
|
||||
|
||||
/// A pan by whole device pixels takes the pixels that stay on screen from the last frame: a mark
|
||||
/// put in that frame moves with the pan, and the frame matches a whole render where the mark is not.
|
||||
#[test]
|
||||
fn a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen() {
|
||||
let (doc, key) = pan_doc();
|
||||
for pan in [(10.0, 0.0), (-10.0, 0.0), (0.0, 7.0), (0.0, -7.0), (-13.0, 5.0)] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
let first = render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0)));
|
||||
assert!(!first.reused, "the first frame renders whole");
|
||||
let k = kept.as_mut().and_then(|k| k.pixels.as_mut()).expect("the frame holds its pixels");
|
||||
// The middle of the frame, which stays on screen.
|
||||
k[(64 * 256 + 128) * 4..(64 * 256 + 128) * 4 + 4].copy_from_slice(&[1, 2, 3, 4]);
|
||||
let job = pan_job(&doc, &key, pan);
|
||||
let frame = render_job(&mut r, &mut kept, &job);
|
||||
assert!(frame.reused, "{pan:?}");
|
||||
let at = ((64 + pan.1 as i64) * 256 + 128 + pan.0 as i64) as usize * 4;
|
||||
assert_eq!(frame.img.pixels[at..at + 4], [1, 2, 3, 4], "{pan:?}: the marked pixel moved with the art");
|
||||
let whole = vectorcraft_render::Renderer::new().render(&doc, 256, 128, job.view, &job.opts);
|
||||
let mut img = frame.img.pixels;
|
||||
img[at..at + 4].copy_from_slice(&whole.pixels[at..at + 4]);
|
||||
assert_eq!(img, whole.pixels, "{pan:?}: the same picture as a whole render");
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame does not copy from the last one when the view moves by part of a pixel or over half the
|
||||
/// frame, when copying is not allowed, or when a listed key field, render option, zoom, rotation,
|
||||
/// canvas size or device scale differs. Each such frame has the job's size. The session test
|
||||
/// compares such frames with a fresh render pixel by pixel.
|
||||
#[test]
|
||||
fn a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed() {
|
||||
let (doc, key) = pan_doc();
|
||||
let other_opts = |f: &dyn Fn(&mut vectorcraft_render::RenderOptions)| {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
f(&mut job.opts);
|
||||
job
|
||||
};
|
||||
let other_key = |f: &dyn Fn(&mut CacheKey)| {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
f(&mut job.key);
|
||||
job
|
||||
};
|
||||
let mut other_doc = (*doc).clone();
|
||||
other_doc.layers.clear();
|
||||
let scaled = {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
job.key.zoom = 2.0;
|
||||
job.view = Affine::translate((10.0, 0.0)) * Affine::scale(2.0);
|
||||
job
|
||||
};
|
||||
let turned = {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
job.key.rot = 30.0;
|
||||
job.view = Affine::translate((10.0, 0.0)) * Affine::rotate(30f64.to_radians());
|
||||
job
|
||||
};
|
||||
let resized = {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
(job.key.w, job.w) = (200, 200);
|
||||
job
|
||||
};
|
||||
let denser = other_key(&|k| k.ppp = 2.0);
|
||||
let not_allowed = crate::render_worker::Job { may_copy: false, ..pan_job(&doc, &key, (10.0, 0.0)) };
|
||||
let cases: Vec<(&str, crate::render_worker::Job)> = vec![
|
||||
("part of a pixel across", pan_job(&doc, &key, (10.5, 0.0))),
|
||||
("part of a pixel down", pan_job(&doc, &key, (0.0, 0.25))),
|
||||
("too far to keep half", pan_job(&doc, &key, (250.0, 0.0))),
|
||||
("zoom", scaled),
|
||||
("rotation", turned),
|
||||
("canvas size", resized),
|
||||
("device scale", denser),
|
||||
("not allowed", not_allowed),
|
||||
("document", other_key(&|k| k.doc += 1)),
|
||||
("revision", crate::render_worker::Job { doc: std::sync::Arc::new(other_doc), ..other_key(&|k| k.revision += 1) }),
|
||||
("interaction", other_key(&|k| k.interacting = true)),
|
||||
("outline key", other_key(&|k| k.outline = true)),
|
||||
("Pixel Preview", other_key(&|k| k.pixel = Some([0, 0, 256, 128]))),
|
||||
("placed bitmaps", other_key(&|k| k.placed += 1)),
|
||||
("outline", other_opts(&|o| o.outline = true)),
|
||||
("proof", other_opts(&|o| o.proof = Some(Default::default()))),
|
||||
("overprint preview", other_opts(&|o| o.overprint_preview = true)),
|
||||
("rich black", other_opts(&|o| o.rich_black = true)),
|
||||
("trim", other_opts(&|o| o.trim = true)),
|
||||
("anti-alias", other_opts(&|o| o.anti_alias = vectorcraft_render::AntiAlias::None)),
|
||||
("smooth images", other_opts(&|o| o.smooth_images = false)),
|
||||
("opacity mask view", other_opts(&|o| o.mask_view = Some(vectorcraft_doc::NodeId(1)))),
|
||||
];
|
||||
let mut copied = vec![];
|
||||
for (name, job) in cases {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0)));
|
||||
let frame = render_job(&mut r, &mut kept, &job);
|
||||
if frame.reused {
|
||||
copied.push(name);
|
||||
}
|
||||
let whole = vectorcraft_render::Renderer::new().render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
assert_eq!((frame.img.width, frame.img.height), (whole.width, whole.height), "{name}");
|
||||
}
|
||||
assert!(copied.is_empty(), "copied from the last frame: {copied:?}");
|
||||
}
|
||||
|
||||
/// The worker keeps a frame only when a later one can copy from it: a frame that does not allow
|
||||
/// keeping, a frame over the limit and a frame that drew a raster from before its object moved are
|
||||
/// not kept. A frame that may not copy can still be kept.
|
||||
#[test]
|
||||
fn the_render_worker_keeps_one_frame_up_to_its_limit() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 16, "y": 16, "width": 64, "height": 64})).unwrap()["id"].clone();
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap();
|
||||
let after = app.session.active().unwrap().doc.clone();
|
||||
let (_, key) = pan_doc();
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
render_job(&mut r, &mut kept, &pan_job(&before, &key, (0.0, 0.0)));
|
||||
assert!(kept.is_some(), "kept");
|
||||
let mut moved = pan_job(&after, &CacheKey { revision: key.revision + 1, interacting: true, ..key.clone() }, (0.0, 0.0));
|
||||
moved.opts.interacting = true;
|
||||
let frame = render_job(&mut r, &mut kept, &moved);
|
||||
assert!(frame.moved > 0, "the shadow drew from the raster made before the move");
|
||||
let mut wrong = vec![];
|
||||
if kept.is_some() {
|
||||
wrong.push("a frame that drew a raster from before its object moved");
|
||||
}
|
||||
|
||||
let (doc, key) = pan_doc();
|
||||
render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0)));
|
||||
assert!(kept.is_some(), "kept");
|
||||
let off = crate::render_worker::Job { may_keep: false, ..pan_job(&doc, &key, (0.0, 0.0)) };
|
||||
render_job(&mut r, &mut kept, &off);
|
||||
if kept.is_some() {
|
||||
wrong.push("a frame that does not allow keeping");
|
||||
}
|
||||
let whole = crate::render_worker::Job { may_copy: false, ..pan_job(&doc, &key, (0.0, 0.0)) };
|
||||
render_job(&mut r, &mut kept, &whole);
|
||||
assert!(kept.is_some(), "a frame that may not copy is kept");
|
||||
let mut big = pan_job(&doc, &key, (0.0, 0.0));
|
||||
(big.key.w, big.key.h, big.w, big.h) = (4097, 4097, 4097, 4097);
|
||||
render_job(&mut r, &mut kept, &big);
|
||||
if kept.is_some() {
|
||||
wrong.push("a frame over 64 MiB");
|
||||
}
|
||||
assert!(wrong.is_empty(), "kept: {wrong:?}");
|
||||
}
|
||||
|
||||
/// Through a pan, an edit, an undo, a zoom, a resize, a view mode and a color setting, each frame the
|
||||
/// worker renders whole matches a fresh render, and each copied frame is within one level of it.
|
||||
#[test]
|
||||
fn worker_frames_match_a_fresh_render_through_a_session() {
|
||||
let (doc, key) = pan_doc();
|
||||
let mut edited = (*doc).clone();
|
||||
edited.layers.reverse();
|
||||
let edited = std::sync::Arc::new(edited);
|
||||
let pan = |at: (f64, f64)| pan_job(&doc, &key, at);
|
||||
let changed = |f: &dyn Fn(&mut crate::render_worker::Job)| {
|
||||
let mut job = pan((4.5, -4.0));
|
||||
f(&mut job);
|
||||
job
|
||||
};
|
||||
let steps: Vec<(&str, crate::render_worker::Job, bool)> = vec![
|
||||
("still", pan((0.0, 0.0)), false),
|
||||
("pan", pan((10.0, 0.0)), true),
|
||||
("pan back", pan((3.0, -4.0)), true),
|
||||
("part of a pixel", pan((3.5, -4.0)), false),
|
||||
("whole again", pan((4.5, -4.0)), true),
|
||||
("settled", changed(&|j| j.may_copy = false), false),
|
||||
("pan after settling", pan((5.5, -4.0)), true),
|
||||
("back before the edit", pan((4.5, -4.0)), true),
|
||||
(
|
||||
"edit",
|
||||
changed(&|j| {
|
||||
j.doc = edited.clone();
|
||||
j.key.revision += 1;
|
||||
}),
|
||||
false,
|
||||
),
|
||||
("undo", pan((4.5, -4.0)), false),
|
||||
(
|
||||
"zoom",
|
||||
changed(&|j| {
|
||||
j.key.zoom = 2.0;
|
||||
j.view = Affine::translate((4.5, -4.0)) * Affine::scale(2.0);
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"resize",
|
||||
changed(&|j| {
|
||||
(j.key.w, j.key.h, j.w, j.h) = (200, 100, 200, 100);
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"outline",
|
||||
changed(&|j| {
|
||||
j.key.outline = true;
|
||||
j.opts.outline = true;
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"rich black",
|
||||
changed(&|j| {
|
||||
j.key.rich_black = true;
|
||||
j.opts.rich_black = true;
|
||||
}),
|
||||
false,
|
||||
),
|
||||
("setting back", pan((4.5, -4.0)), false),
|
||||
("first pan after the setting", pan((5.5, -4.0)), false),
|
||||
("next pan", pan((6.5, -4.0)), true),
|
||||
];
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
for (name, job, copies) in steps {
|
||||
let frame = render_job(&mut r, &mut kept, &job);
|
||||
assert_eq!(frame.reused, copies, "{name}");
|
||||
let whole = vectorcraft_render::Renderer::new().render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
if copies {
|
||||
let off = frame.img.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(off.is_some_and(|d| d <= 1), "{name}: {off:?}");
|
||||
} else {
|
||||
assert_eq!(frame.img.pixels, whole.pixels, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pan over art the frame's size decides stays within one level of a fresh render of the frame,
|
||||
/// because it renders whole: a full-page raster effect whose raster a part of the frame would make
|
||||
/// at another scale, and a group drawn offscreen across the exposed part's edge with an Inner Glow
|
||||
/// child inside the part, whose filter layer starts at another pixel in the part. The worker does
|
||||
/// not keep such a frame for the pan to copy from.
|
||||
#[test]
|
||||
fn a_pan_over_art_the_frame_size_decides_renders_whole() {
|
||||
let (_, key) = pan_doc();
|
||||
let effect = |effect: &str, params: serde_json::Value| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": "#1a4de6"})).unwrap();
|
||||
app.run("effect.apply", json!({"effect": effect, "ids": [id], "params": params})).unwrap();
|
||||
app.session.active().unwrap().doc.clone()
|
||||
};
|
||||
let group_glow = {
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::{Rect, shapes::rectangle};
|
||||
let mut d = vectorcraft_doc::Document::new(1600.0, 1200.0);
|
||||
let basic = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
|
||||
let wide = Node::path(d.alloc_id(), rectangle(Rect::new(100.0, 100.0, 1590.0, 900.0)), basic(Color::rgb(0.9, 0.8, 0.3)));
|
||||
let mut child = Node::path(d.alloc_id(), rectangle(Rect::new(1453.0, 200.0, 1583.0, 500.0)), basic(Color::rgb(0.1, 0.3, 0.9)));
|
||||
child.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: json!({"blur": 40}), visible: true }];
|
||||
child.blend = BlendMode::Multiply;
|
||||
let mut group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(wide), std::sync::Arc::new(child)]);
|
||||
group.isolate = true;
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, group).unwrap();
|
||||
std::sync::Arc::new(d)
|
||||
};
|
||||
let cases = [
|
||||
("Gaussian Blur 500 pt", effect("blur.gaussian", json!({"radius": 500})), (-200.0, -200.0), 2.0, (1440, 870)),
|
||||
("Craquelure", effect("texture.craquelure", json!({})), (-200.0, -200.0), 10.0, (3840, 2160)),
|
||||
("Inner Glow in a group drawn offscreen", group_glow, (0.0, 0.0), 1.0, (1600, 1200)),
|
||||
];
|
||||
for (name, doc, (x, y), zoom, (w, h)) in cases {
|
||||
let job = |dx: f64| {
|
||||
let mut job = pan_job(&doc, &key, (dx, 0.0));
|
||||
(job.key.w, job.key.h, job.w, job.h, job.key.zoom) = (w, h, w, h, zoom);
|
||||
job.view = Affine::translate((x + dx, y)) * Affine::scale(zoom);
|
||||
job
|
||||
};
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
render_job(&mut r, &mut kept, &job(0.0));
|
||||
let pan = job(-40.0);
|
||||
let frame = render_job(&mut r, &mut kept, &pan);
|
||||
let whole = vectorcraft_render::Renderer::new().render(&pan.doc, pan.w, pan.h, pan.view, &pan.opts);
|
||||
let off = frame.img.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(off.is_some_and(|d| d <= 1), "{name}: a channel differs by {off:?}");
|
||||
assert!(!frame.reused, "{name}: the pan renders whole");
|
||||
assert!(kept.is_none(), "{name}: the frame is not kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `test`, whose canvas key holds the global placed-document bitmap clock, which tests on
|
||||
/// other threads move: a frame whose key changed by more than the view renders whole. An attempt
|
||||
/// that fails while the clock moved runs again; one that fails while it stood still is a real
|
||||
/// failure.
|
||||
fn while_the_placed_clock_stands_still(test: fn()) {
|
||||
for _ in 0..20 {
|
||||
let clock = vectorcraft_render::placed_document::generation();
|
||||
match std::panic::catch_unwind(test) {
|
||||
Ok(()) => return,
|
||||
Err(_) if vectorcraft_render::placed_document::generation() != clock => {}
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
test();
|
||||
}
|
||||
|
||||
/// The canvas asks the worker for a frame that may copy pixels when the view moves, keeps the whole
|
||||
/// frame's time, and renders the frame whole once the view has been still for STILL_SECS, not
|
||||
/// before and only once.
|
||||
#[test]
|
||||
fn a_frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
while_the_placed_clock_stands_still(frame_with_copied_pixels_renders_whole_once_the_view_stops);
|
||||
}
|
||||
|
||||
fn frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.canvas.worker_started = true;
|
||||
let (worker, jobs, done_tx) = crate::render_worker::Worker::test_channel();
|
||||
app.canvas.worker = Some(worker);
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp, t: f64| {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, 1.0);
|
||||
done_tx.send(finished(&jobs.try_recv().expect("the first frame"))).unwrap();
|
||||
frame(&mut app, 1.0);
|
||||
assert_eq!(app.canvas.last_ms, 40.0);
|
||||
assert!(app.canvas.reused.is_none());
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 2.0);
|
||||
let pan = jobs.try_recv().expect("the pan's frame goes to the worker");
|
||||
assert!(pan.may_copy, "a new view may copy");
|
||||
done_tx.send(crate::render_worker::Done { ms: 2.0, reused: true, ..finished(&pan) }).unwrap();
|
||||
frame(&mut app, 3.0);
|
||||
assert_eq!(app.canvas.reused, Some(3.0));
|
||||
assert_eq!(app.canvas.last_ms, 40.0, "a frame with copied pixels keeps the whole frame's time");
|
||||
assert_eq!(app.perf.render_ms, 2.0);
|
||||
|
||||
frame(&mut app, 3.0 + STILL_SECS / 2.0);
|
||||
assert!(jobs.try_recv().is_err(), "the view has not been still long enough");
|
||||
frame(&mut app, 3.0 + STILL_SECS);
|
||||
let settle = jobs.try_recv().expect("the frame renders whole");
|
||||
assert!(!settle.may_copy, "the settling frame copies nothing");
|
||||
assert_eq!(settle.key, pan.key);
|
||||
frame(&mut app, 3.0 + STILL_SECS * 2.0);
|
||||
assert!(jobs.try_recv().is_err(), "queued once");
|
||||
done_tx.send(finished(&settle)).unwrap();
|
||||
frame(&mut app, 4.0);
|
||||
assert!(app.canvas.reused.is_none());
|
||||
frame(&mut app, 5.0);
|
||||
assert!(jobs.try_recv().is_err(), "nothing to render after it");
|
||||
}
|
||||
|
||||
/// The frame that renders whole after a pan stops copies nothing and stays on the worker, so the
|
||||
/// first step of the next pan copies from it. The jobs are the ones the canvas asks for.
|
||||
#[test]
|
||||
fn a_pan_after_the_settling_frame_copies() {
|
||||
while_the_placed_clock_stands_still(pan_after_the_settling_frame_copies);
|
||||
}
|
||||
|
||||
fn pan_after_the_settling_frame_copies() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.canvas.worker_started = true;
|
||||
let (worker, jobs, done_tx) = crate::render_worker::Worker::test_channel();
|
||||
app.canvas.worker = Some(worker);
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp, t: f64| {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
||||
};
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
let mut render = |name: &str, copies: bool| {
|
||||
let job = jobs.try_recv().unwrap_or_else(|_| panic!("{name}: the canvas asks for a frame"));
|
||||
let f = render_job(&mut r, &mut kept, &job);
|
||||
assert_eq!(f.reused, copies, "{name}");
|
||||
let done = crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img: f.img, ms: 40.0, moved: f.moved, reused: f.reused };
|
||||
done_tx.send(done).unwrap();
|
||||
};
|
||||
frame(&mut app, 1.0);
|
||||
render("the first frame", false);
|
||||
frame(&mut app, 1.0);
|
||||
// At 100% the view moves by whole device pixels.
|
||||
app.view_mut().unwrap().zoom = 1.0;
|
||||
frame(&mut app, 1.5);
|
||||
render("the zoom", false);
|
||||
frame(&mut app, 1.5);
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 1.75);
|
||||
render("the first pan after the zoom", false);
|
||||
frame(&mut app, 1.75);
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 2.0);
|
||||
render("the pan", true);
|
||||
frame(&mut app, 3.0);
|
||||
frame(&mut app, 3.0 + STILL_SECS);
|
||||
render("the settling frame", false);
|
||||
frame(&mut app, 4.0);
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 5.0);
|
||||
render("the next pan", true);
|
||||
}
|
||||
|
||||
/// A frame after a zoom or an edit holds no pixels, and the frame before it lets its pixels go. The
|
||||
/// first pan step after it renders whole and holds its pixels, and the next step copies them.
|
||||
#[test]
|
||||
fn a_frame_after_a_zoom_or_an_edit_holds_no_pixels() {
|
||||
let (doc, key) = pan_doc();
|
||||
let mut edited = (*doc).clone();
|
||||
edited.layers.reverse();
|
||||
let edited = std::sync::Arc::new(edited);
|
||||
let zoomed = |key: &CacheKey, at: f64, zoom: f64| {
|
||||
let mut job = pan_job(&doc, key, (at, 0.0));
|
||||
job.key.zoom = zoom;
|
||||
job.view = Affine::translate((at, 0.0)) * Affine::scale(zoom);
|
||||
job
|
||||
};
|
||||
let revised = CacheKey { revision: key.revision + 1, ..key.clone() };
|
||||
let steps: Vec<(&str, crate::render_worker::Job, bool, bool)> = vec![
|
||||
("the first frame", pan_job(&doc, &key, (0.0, 0.0)), false, true),
|
||||
("a zoom", zoomed(&key, 0.0, 2.0), false, false),
|
||||
("another zoom", zoomed(&key, 0.0, 1.5), false, false),
|
||||
("the first pan after the zoom", zoomed(&key, 10.0, 1.5), false, true),
|
||||
("the next pan", zoomed(&key, 20.0, 1.5), true, true),
|
||||
("an edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (0.0, 0.0)) }, false, false),
|
||||
("the first pan after the edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (10.0, 0.0)) }, false, true),
|
||||
("the next pan after the edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (20.0, 0.0)) }, true, true),
|
||||
];
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
for (name, job, copies, holds) in steps {
|
||||
assert_eq!(render_job(&mut r, &mut kept, &job).reused, copies, "{name}: copied");
|
||||
assert_eq!(kept.as_ref().is_some_and(|k| k.pixels.is_some()), holds, "{name}: holds its pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker thread itself copies pixels on a pan and reports it.
|
||||
#[test]
|
||||
fn the_render_worker_thread_copies_a_pan() {
|
||||
let (doc, key) = pan_doc();
|
||||
let mut worker = crate::render_worker::Worker::spawn(egui::Context::default()).expect("a worker");
|
||||
// Blocks until the worker sends its frame. A worker thread that stopped gives None.
|
||||
let wait = |worker: &mut crate::render_worker::Worker| worker.wait(std::time::Duration::MAX).expect("the worker sends the frame");
|
||||
worker.submit(pan_job(&doc, &key, (0.0, 0.0)));
|
||||
assert!(!wait(&mut worker).reused);
|
||||
worker.submit(pan_job(&doc, &key, (10.0, 0.0)));
|
||||
assert!(wait(&mut worker).reused);
|
||||
}
|
||||
|
||||
/// #4155: a document's first frame renders on the render worker, not on the UI thread, where a
|
||||
|
||||
@@ -24,6 +24,12 @@ pub struct Job {
|
||||
/// The automation roots in force where the job was made: the render reads linked files under
|
||||
/// them, as it would on that thread.
|
||||
pub scope: ScopeHandle,
|
||||
/// The render may copy pixels from the worker's last frame ([`crate::canvas::render_job`]).
|
||||
/// Off: it renders the whole frame.
|
||||
pub may_copy: bool,
|
||||
/// The worker may keep the frame for its next frame to copy from
|
||||
/// ([`crate::canvas::render_job`]). Off: the worker holds no frame after this one.
|
||||
pub may_keep: bool,
|
||||
}
|
||||
|
||||
pub struct Done {
|
||||
@@ -35,6 +41,9 @@ pub struct Done {
|
||||
pub ms: f64,
|
||||
/// [`vectorcraft_render::FrameStats::moved`] of the frame.
|
||||
pub moved: usize,
|
||||
/// The frame holds pixels copied from the worker's last frame ([`crate::canvas::render_job`]),
|
||||
/// and `ms` is the time of the copy and of the parts rendered.
|
||||
pub reused: bool,
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
@@ -55,15 +64,17 @@ impl Worker {
|
||||
.name("vectorcraft-render".into())
|
||||
.spawn(move || {
|
||||
let mut r = Renderer::new();
|
||||
let mut kept = None;
|
||||
while let Ok(mut job) = jobs.recv() {
|
||||
// Skip stale jobs: only the newest matters.
|
||||
while let Ok(newer) = jobs.try_recv() {
|
||||
job = newer;
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let img = job.scope.clone().run(|| r.render(&job.doc, job.w, job.h, job.view, &job.opts));
|
||||
if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err()
|
||||
{
|
||||
let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job));
|
||||
let ms = crate::now_ms() - t0;
|
||||
let done = Done { key: job.key, doc: job.doc, opts: job.opts, img: frame.img, ms, moved: frame.moved, reused: frame.reused };
|
||||
if done_tx.send(done).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
|
||||
+15
@@ -534,6 +534,21 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o
|
||||
`effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects;
|
||||
`apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`.
|
||||
|
||||
On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`,
|
||||
`appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`,
|
||||
`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint,
|
||||
weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars,
|
||||
wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has
|
||||
no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has
|
||||
paint, and has a 0.25 pt black outline, so these commands do not store a stroke paint or weight for a series that
|
||||
draws a band. They still store a stroke opacity set on a band, and the band's outline takes it. Graph
|
||||
designs keep their own paint. A row that had no paint before the command and has none after it does not change what the
|
||||
graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other
|
||||
store the topmost row of the kind even when it is hidden, and store a stroke set on a band.
|
||||
`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a
|
||||
series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not
|
||||
store them.
|
||||
|
||||
`effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries
|
||||
it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape
|
||||
those outlines. On paths, compound paths and images it does not change what is drawn.
|
||||
|
||||
@@ -849,7 +849,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
|
||||
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the opacity set on a mark's top fill or stroke row, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
|
||||
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
|
||||
Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
@@ -1416,6 +1416,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Zooming in on large images (#4155):** when an image is drawn from pixels (its mip, or its full pixels) more than 1,024 pixels on a side, and their colours are made again for the frame (separated into a CMYK document's ink planes, colorized, or shown through Separations Preview's plates), only the 1,024-pixel tiles in view are made, each kept with the images under its own key, and the tiles in view are drawn as one pixmap so their edges don't show. The colours are made pixel by pixel, so the result is the same as making them for the whole image (`a_tiled_image_draws_what_the_whole_image_draws`, across a tile edge); `a_zoomed_in_image_is_separated_only_where_it_shows` checks the tiles out of view are not made. A CMYK image painting its own inks is still made whole. On the file of #4155 (an 11,339 × 12,424 photo, M3 Pro, one run each), the sharp frame after zooming in to 50%, 100% and 200% went from 9.0, 8.6 and 8.7 s to 0.78, 0.05 and 0.05 s, and the app's memory after zooming from 4.4 GB to 2.6 GB; the screens at 100% match (p99 difference 0).
|
||||
- **Large images in CMYK documents (#4155):** the canvas draws a reduced image from a mip (halved while still at least as fine as the screen) and, in a CMYK document, separates that mip into its ink planes, colorizes it or shows it through Separations Preview's plates, instead of doing that to the full pixels and shrinking the result. A CMYK image's own inks are halved with its pixels, so it still paints them. Exports draw without mips and are unchanged. On the file of #4155 (one 11,339 × 12,424 image), measured on an M3 Pro under load, one run each: first frame 9.2 s → 1.6 s, opening to the first frame 11.7 s → 3.9 s, the sharp frame after a pan 3.9 s → 8 ms and after a zoom 3.5 s → 0.16 s; the fitted view's pixels differ by at most 5 levels (p99 2). `a_reduced_image_is_separated_from_its_mip_not_its_full_pixels`, `a_reduced_cmyk_image_paints_its_own_inks` and `a_reduced_image_on_the_ink_planes_shows_its_average` (`crates/render/src/tests/memory.rs`) cover the order, the inks and the colour: 1-pixel black and white lines show as the mid grey they average to, where separating first showed them 44 levels darker.
|
||||
- **Panning over a cropped effect raster:** when a pan makes a shadow, glow or filtered content raster cropped to the view again at the same zoom and rotation, the renderer makes it over a margin around the view too (up to half the view's longer side, within 8.4 million pixels), and later pans that keep the view inside that margin draw the raster instead of making it again. Zooming and the first frame at a zoom make the raster without the margin. The canvas pans by whole device pixels: the wheel, a trackpad, a touch screen and the scroll bars add the part of a pixel left over to the next scroll of the same view, and Hand tool and scroll bar thumb drags round the distance dragged. A Drop Shadow or Outer Glow raster holds the shadow or glow of group members outside the view, so a pan that shows a member draws its shadow or glow from the raster. `pans_within_a_cropped_rasters_margin_draw_it_kept` covers Drop Shadow, Gaussian Blur and Feather on a large image at 100%. `a_groups_shadow_raster_holds_the_members_outside_the_view` covers a group's members, `fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept` covers scrolls by fractions of a pixel, and `scrolls_and_pans_move_the_view_by_whole_device_pixels` covers the canvas.
|
||||
- **Panning a heavy document:** when the render worker draws a frame for a view moved by whole device pixels at the same zoom and rotation, it copies the pixels that stay on screen from its last frame and renders only the parts the pan exposes, grown to whole eighths of the frame, as long as those parts are at most half of it. A pan renders whole when the last frame or a part it exposes draws art in a way that depends on the frame's size, such as an effect raster cropped to the view or made at a lower resolution. A copied frame can differ from a whole render of the view (by at most one level in the tests), so the canvas renders the frame whole once it has been shown for 0.2 s with the view unmoved. The worker keeps one extra frame, up to 16 million pixels (64 MiB). A frame after a zoom or an edit holds no pixels, so the first pan step after one renders whole. Scores and estimates unchanged. `a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen`, `a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed`, `worker_frames_match_a_fresh_render_through_a_session`, `a_pan_over_art_the_frame_size_decides_renders_whole`, `a_frame_after_a_zoom_or_an_edit_holds_no_pixels` and `a_frame_with_copied_pixels_renders_whole_once_the_view_stops` (`crates/ui-egui/src/canvas/tests.rs`) cover the copy, the frames that render whole, the frames that hold no pixels and the frame after the view stops.
|
||||
|
||||
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user