Merge main into fix/script-console-report-text

This commit is contained in:
dotCooCoo
2026-11-11 03:28:54 -08:00
70 changed files with 2786 additions and 142 deletions
+21 -3
View File
@@ -136,9 +136,11 @@ impl crate::Document {
}
}
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format
/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the
/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para`
/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each
/// label's alignment.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LabelStyles {
@@ -148,6 +150,22 @@ pub struct LabelStyles {
pub categories: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub values_para: Option<crate::ParaStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories_para: Option<crate::ParaStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend_para: Option<crate::ParaStyle>,
/// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method).
/// `None` draws them Sharp.
#[serde(skip_serializing_if = "Option::is_none")]
pub values_anti_alias: Option<crate::text::TypeAntiAlias>,
/// The anti-aliasing method the category labels keep once set. `None` draws them Sharp.
#[serde(skip_serializing_if = "Option::is_none")]
pub categories_anti_alias: Option<crate::text::TypeAntiAlias>,
/// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp.
#[serde(skip_serializing_if = "Option::is_none")]
pub legend_anti_alias: Option<crate::text::TypeAntiAlias>,
}
impl LabelStyles {
+42
View File
@@ -159,6 +159,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud },
BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects },
];
/// Built-in library `id`, made once.
@@ -482,6 +483,43 @@ fn game_hud() -> Vec<GraphicStyle> {
]
}
/// Status Effects: buffs and debuffs for RPG/gamedev art (original, catalogue stylize effects only).
fn status_effects() -> Vec<GraphicStyle> {
let stealth = GraphicStyle {
opacity: 0.45,
blend: BlendMode::Normal,
..style("Stealth Fade", vec![F(fill("#7f8c8d")), S(stroke("#2c3e50", 1.0))], vec![])
};
vec![
style(
"Poison Tint",
vec![F(fill("#2ecc71"))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 80.0, "blur": 6.0, "color": "#58d68d"}))],
),
style(
"Freeze Edge",
vec![F(fill("#aed6f1")), S(stroke("#5dade2", 2.0))],
vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 5.0, "color": "#d6eaf8", "source": "edge"}))],
),
style(
"Burn Aura",
vec![F(fill("#e67e22"))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 8.0, "color": "#f39c12"}))],
),
style(
"Stun Pulse",
vec![F(fill("#f4d03f"))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 7.0, "color": "#f7dc6f"}))],
),
stealth,
style(
"Shield Buff",
vec![F(fill("#85c1e9")), S(round("#2874a6", 2.0))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#5dade2"}))],
),
]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -508,6 +546,10 @@ mod tests {
assert_eq!(game.name, "Game HUD");
let names: Vec<&str> = game.styles.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]);
let status = builtin_style_library("status-effects").unwrap();
assert_eq!(status.name, "Status Effects");
let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect();
assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]);
}
#[test]
+1 -1
View File
@@ -395,7 +395,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
if !kept {
return None;
}
reshape_leaves(&mut m, &fx, Some(d));
reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d));
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
m.appearance.items.clear();
m.appearance.contents_index = None;
+17 -3
View File
@@ -26,6 +26,10 @@ pub struct GeomContext<'a> {
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
/// pieces are looked up in. Without one, a result that names any is refused.
pub doc: Option<&'a Document>,
/// The object, fill or stroke and effect index a deferred live-effect plug-in is listed by
/// ([`vectorcraft_plugins::deferred`]). When `None`, it is the object `seed` names, `item` and
/// the effect's index in the list being applied.
pub stage: Option<vectorcraft_plugins::deferred::Stage>,
}
impl<'a> GeomContext<'a> {
@@ -36,7 +40,17 @@ impl<'a> GeomContext<'a> {
_ => FillRule::NonZero,
};
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None, stage: None }
}
/// The context of effect `e`, one of `list`: unless [`Self::stage`] is set, a live-effect
/// plug-in there is listed by `e`'s index in `list`.
pub(crate) fn at(self, list: &[Effect], e: &Effect) -> Self {
let stage = self.stage.or_else(|| {
let effect = list.iter().position(|x| std::ptr::eq(x, e))?;
Some(vectorcraft_plugins::deferred::Stage { object: self.seed, effect, item: self.item })
});
Self { stage, ..self }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@@ -335,7 +349,7 @@ pub(crate) fn apply_geometry_carrying_within(
}
let p = merged_params(&e.id, &e.params);
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
if let Some(next) = apply_one(&e.id, &p, &cur, b, &ctx.at(effects, e), limit) {
if let Some(c) = carry.as_deref_mut() {
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
}
@@ -377,7 +391,7 @@ fn builds_within(id: &str) -> bool {
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
use util::{join, num};
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item, at: ctx.stage };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
}
match id {
+3 -3
View File
@@ -209,7 +209,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
Some(area) => Some(reshaped(&area, n, doc)),
None => {
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
// The group's own fills and strokes follow the effects as its members do.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
@@ -225,7 +225,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(doc, &mut art, false);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
// The art's own fills and strokes follow the effects as its members do.
if carry.moves(&art.appearance) {
carry.apply(&mut art.appearance);
@@ -738,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(doc, &m);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
// The group's own fills and strokes paint the members as a whole: their gradients follow
// the effects as the members' do.
if paint && carry.moves(&m.appearance) {
+3 -2
View File
@@ -161,7 +161,8 @@ pub fn any_plugin_paints_output() -> bool {
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred
/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]).
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32)
}
+52 -28
View File
@@ -573,7 +573,7 @@ fn as_art_opts(
}
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
};
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
reshape_leaves_apart(&mut art, &fx, &n.appearance.effects, Budget::EFFECTS, apart, paint_doc);
if apart {
hoist_copies(&mut art);
}
@@ -636,21 +636,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`
/// ([`reshape_leaves_apart`]).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc);
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) {
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc);
}
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
/// strokes) follow the effects ([`crate::Carry`]).
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc)
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) -> crate::Carry {
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc)
}
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
#[cfg(test)]
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry {
reshape_leaves_apart(art, fx, budget, false, doc)
reshape_leaves_apart(art, fx, &[], budget, false, doc)
}
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
@@ -669,12 +670,15 @@ fn hoist_copies(art: &mut Node) {
}
}
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost; with
/// `apart`, a Transform effect's copies of each path are objects
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
/// Returns how gradients that paint the art as a whole follow the effects.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
/// `owner` does not hold it.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
let mut whole = crate::Carry::default();
if fx.is_empty() {
return whole;
@@ -693,6 +697,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
});
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
let style_safe = leaves_safe && !art_stroke;
let listed = fx;
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
let fx = opt_fx.as_slice();
let seed = art.id.0;
@@ -703,12 +708,15 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
if let Some(plugin) = painted_plugin(e) {
let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
for_each_leaf(art, &mut |leaf| {
let Some((path, _)) = leaf_geometry(leaf) else { return };
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc) {
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc, at) {
used = used.minus(before).plus(made);
}
});
@@ -778,7 +786,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
return;
}
}
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
return;
};
@@ -1056,10 +1065,18 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces
/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost
/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are
/// refused, or they would pass `budget`.
fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option<Budget> {
/// refused, or they would pass `budget`. A deferred run is listed by `at`.
fn paint_leaf(
leaf: &mut Node,
plugin: &str,
params: &serde_json::Value,
bounds: Rect,
budget: Budget,
doc: Option<&Document>,
at: Option<vectorcraft_plugins::deferred::Stage>,
) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None };
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None, at };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
@@ -1097,18 +1114,18 @@ fn piece_cost(path: &PathData) -> Budget {
/// other fills and strokes; one that gives none keeps the leaf's. Returns
/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails,
/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass
/// `budget`.
/// `budget`. A deferred run is listed by `at`.
fn paint_piece(
leaf: &mut Node,
item: &AppearanceItem,
plugin: &str,
params: &serde_json::Value,
(plugin, params): (&str, &serde_json::Value),
bounds: Rect,
budget: Budget,
doc: Option<&Document>,
at: Option<vectorcraft_plugins::deferred::Stage>,
) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) };
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
@@ -1241,6 +1258,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
};
let Some(tb) = g.bounds() else { continue };
let params = merged_params(&fx[at].id, &fx[at].params);
// Where each effect the piece keeps sits among the item's effects.
let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect();
if let Some(plugin) = painted_plugin(&fx[at]) {
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
@@ -1250,7 +1269,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
carry.apply(&mut one);
}
let it = one.items.pop().unwrap_or(it);
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) };
let stage = vectorcraft_plugins::deferred::Stage { object: ctx.seed, effect: at, item: Some(i) };
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i), at: Some(stage) };
let painted = vectorcraft_plugins::effect::apply_painted(plugin, &params, &g, tb, rule, paints, doc)
.map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::<Vec<_>>())
.filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus)));
@@ -1264,7 +1284,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
group.push(Arc::new(p));
}
let mut p = Node::group(leaf.id, group);
transform_piece(&mut p, ctx.seed, limit, used, doc);
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
pieces.push(Arc::new(p));
continue;
}
@@ -1281,7 +1301,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
set_geometry(&mut p, g.clone(), rule);
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
*used = used.plus(made);
transform_piece(&mut p, ctx.seed, limit, used, doc);
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
@@ -1297,14 +1317,17 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`.
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) {
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source,
/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the
/// piece was made from, and the index among that item's effects of each effect the piece kept,
/// which a deferred live-effect plug-in is listed by.
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) {
let mut count = 0;
for_each_leaf(p, &mut |l| {
if let Some(item) = l.appearance.items.first() {
@@ -1320,8 +1343,9 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
// What the other paths leave of the budget.
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) };
if let Some(plugin) = painted_plugin(&e) {
if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) {
if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) {
*used = used.minus(before).plus(made);
}
return;
@@ -1340,7 +1364,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) };
let mut carry = crate::Carry::default();
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
*used = used.minus(before).plus(Budget::of(&out));
+83 -3
View File
@@ -21,8 +21,8 @@ use serde_json::{Map, Value, json};
use vectorcraft_brush::Brush;
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration,
TextStyleDef,
Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice,
SymbolRegistration, TextStyleDef,
};
use super::super::brushsym::{set_natural_size, stored_natural_size};
@@ -140,6 +140,31 @@ fn node_refs(n: &Node, f: Visit) {
});
}
/// The `(name, xml)` of `e` when it is an SVG filter effect.
fn filter_of(e: &Effect) -> Option<(String, String)> {
let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string);
if e.id != Appearance::SVG_FILTER {
return None;
}
Some((text("name")?, text("xml")?))
}
/// The SVG filter effects of appearance `a` (its own and its fills' and strokes').
fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) {
let items = a.items.iter().flat_map(|i| i.effects().iter());
out.extend(a.effects.iter().chain(items).filter_map(filter_of));
}
/// The SVG filter effects in the subtree of `n` (opacity-mask art included).
fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) {
n.walk(&mut |m| {
appearance_filters(&m.appearance, out);
if let Some(mask) = &m.extras().mask {
node_filters(&mask.art, out);
}
});
}
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
fn is_base(w: &Swatch) -> bool {
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
@@ -527,6 +552,9 @@ impl SwatchChoices {
#[derive(Clone, Debug, Default)]
struct Relink {
renames: BTreeMap<Res, BTreeMap<String, String>>,
/// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document
/// ([`Clipboard::filter_links`]).
filters: BTreeMap<(String, String), (String, String)>,
/// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped.
styles: BTreeMap<u32, u32>,
/// Swatch (its name in the document) → the colour linked colours show at 100 %.
@@ -566,6 +594,15 @@ impl Relink {
});
}
fn appearance(&self, a: &mut Appearance) {
if !self.filters.is_empty() {
let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
for e in a.effects.iter_mut().chain(items) {
if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) {
e.params["name"] = json!(name);
e.params["xml"] = json!(xml);
}
}
}
for it in &mut a.items {
self.paint(it.paint_mut());
if let AppearanceItem::Stroke(l) = it
@@ -1013,6 +1050,7 @@ impl Clipboard {
/// of definitions that use each other is matched on its own ([`loop_groups`]).
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
let mut out = Imported::default();
out.relink.filters = self.filter_links(d, same_doc);
self.import_images(d, used, same_doc, &mut out);
self.import_swatches(d, used, choices, same_doc, &mut out);
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
@@ -1170,10 +1208,52 @@ impl Clipboard {
}
vectorcraft_brush::store(d, &lib);
}
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
out.relinks = !(out.relink.renames.is_empty()
&& out.relink.filters.is_empty()
&& out.relink.styles.is_empty()
&& out.relink.colors.is_empty()
&& used.styles.is_empty());
out
}
/// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's
/// filter of its name; one that draws another definition than that filter takes the document's
/// filter when pasted back into the document it came from, and elsewhere keeps its own under a
/// free name, so editing or deleting the document's filter leaves it alone.
fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> {
let mut found = BTreeSet::new();
for n in &self.nodes {
node_filters(n, &mut found);
}
for s in &self.symbols {
node_filters(&s.art, &mut found);
}
for a in self.patterns.iter().flat_map(|p| p.art.iter()) {
node_filters(a, &mut found);
}
for a in self.brushes.iter().flat_map(Brush::art) {
node_filters(a, &mut found);
}
for g in &self.graphic_styles {
appearance_filters(&g.appearance, &mut found);
}
let mut taken: BTreeSet<String> = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect();
let mut links = BTreeMap::new();
for (name, xml) in found {
let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue };
let to = if same_doc {
(name.clone(), held.xml.clone())
} else {
let new = unique_name(&name, |n| taken.contains(n));
taken.insert(new.clone());
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new).unwrap_or_else(|| xml.clone());
(new, drawn)
};
links.insert((name, xml), to);
}
links
}
/// Image blobs: a different blob under a key the document uses gets a new key.
fn import_images(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) {
for (key, blob) in self.images.iter().filter(|(k, _)| used.has(Res::Image, k)) {
+146
View File
@@ -109,9 +109,155 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
move_effect
),
cmd!(
query "effect.deferredStatus",
"Deferred Effects",
[],
None,
"{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)",
has_doc,
deferred_status
),
cmd!(
"effect.refreshDeferred",
"Refresh Deferred Effects",
["Effect"],
None,
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
has_deferred,
refresh_deferred
),
]
}
/// The active document has deferred live-effect plug-in stages ([`deferred_status`]).
fn has_deferred(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) }
}
/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers.
/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect
/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks.
fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
let mut out = std::collections::HashMap::new();
let mut rest = Vec::new();
for object in fx.list().into_iter().map(|x| x.stage.object) {
match d.node_arc(NodeId(object)) {
Some(n) => {
out.insert(object, (n.clone(), true));
}
None => rest.push(object),
}
}
if rest.is_empty() {
return out;
}
let brushes = vectorcraft_brush::library(d);
let symbols = d.symbols.iter().map(|s| &*s.art);
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
let layers = d.layers.iter().map(|a| &**a);
let mut look = |n: &Node| {
if rest.contains(&n.id.0) {
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
}
};
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
vectorcraft_doc::swatches::walk_with_masks(a, &mut look);
}
out
}
/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible,
/// on a visible fill or stroke when it is one's?
fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool {
let Some(n) = n else { return false };
let list = match stage.item {
None => Some(n.appearance.effects.as_slice()),
Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()),
};
list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin))
}
/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place
/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]).
fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
let objects = stage_objects(d, fx);
fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin));
objects
}
/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose
/// object or plug-in effect is no longer at their place in `d` are dropped first.
fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec<Value> {
prune(d, fx);
fx.list()
.into_iter()
.map(|x| {
let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()});
if let Some(i) = x.stage.item {
v["item"] = json!(i);
}
v
})
.collect()
}
/// `effect.deferredStatus`.
fn deferred_status(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)}))
}
/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that
/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left
/// out for its size?
fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc<Node>) -> bool {
let Some(b) = n.geometric_bounds() else { return false };
let scale = 64.0 / b.width().max(b.height()).max(1.0);
let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0));
renderer.stats = Default::default();
renderer.render_node(doc, n, 66, 66, view);
renderer.stats.culled == 0
}
/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small,
/// inside the document's view, so its stages are run with the input they were deferred with; a
/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a
/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are
/// drawn then. A stage input left out of a small drawing for its size stays deferred.
fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
let ids = super::named_ids(p)?;
let objects = prune(&doc, &fx);
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
return Err(EngineError::NoNode(*id));
}
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
let counts = fx.refresh(ids.as_deref(), |targets| {
let mut renderer = vectorcraft_render::Renderer::new();
let mut whole = Vec::new();
fx.view(|| {
let mut elsewhere = false;
for id in targets {
match objects.get(id) {
Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id),
Some((_, false)) => elsewhere = true,
_ => {}
}
}
if elsewhere {
for layer in doc.layers.iter().filter(|l| l.visible) {
draw_small(&mut renderer, &doc, layer);
}
}
});
whole
});
Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed}))
}
fn ids_json(ids: &[NodeId]) -> Value {
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
}
+3
View File
@@ -484,6 +484,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
let mut scale = ppi / 72.0;
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
// No side longer than the renderer draws: the image grows by a pixel on each side and is
// rounded up to whole pixels below.
scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0));
let px = 1.0 / scale;
let rect = rect.inflate(px, px);
let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c));
+1 -1
View File
@@ -10,7 +10,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach; an image larger than 64 Mpx or 32,768 px a side is made at a lower resolution), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_doc,
flatten
),
+2 -2
View File
@@ -43,8 +43,8 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets,
series_object_opacity_differs, shown_paint,
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+12 -8
View File
@@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada
use super::columns::{Allowance, ColumnArt, column_art, column_designs};
use super::data::fmt_value;
use super::labels::{
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of,
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, 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};
@@ -901,18 +902,21 @@ impl<'a> Plot<'a> {
fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc<Node> {
let ls = self.ls;
let (dx, dy) = label_shift(&self.g.label_styles, label, self.b.d.setup.system_layout);
let (x, y) = (x + dx, y + dy);
self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)
}
/// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row
/// just above it.
fn legend_across_top(&mut self) -> Vec<Arc<Node>> {
let (g, r, ls) = (self.g, self.r, self.ls);
let (g, r, ls, system_layout) = (self.g, self.r, self.ls, self.b.d.setup.system_layout);
let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect();
let mut rows: Vec<Vec<(usize, String, f64)>> = vec![vec![]];
let mut rows: Vec<Vec<(usize, String, f64, f64)>> = vec![vec![]];
let mut used = 0.0;
for (s, label) in entries {
let w = 12.0 + label_width(&g.label_styles, &label) + 12.0;
let w = 12.0 + label_width(&g.label_styles, &label, system_layout) + 12.0;
let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label, system_layout);
// The gap after an entry may run past the plot's edge.
if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) {
rows.push(vec![]);
@@ -920,10 +924,10 @@ impl<'a> Plot<'a> {
}
used += w;
if let Some(row) = rows.last_mut() {
row.push((s, label, w));
row.push((s, label, w, h));
}
}
let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64;
let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max);
// Above the plot and anything drawn over it (radar and stacked-pie labels).
let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min);
let top = self.bars_reach.map_or(top, |b| top.min(b.y0));
@@ -931,7 +935,7 @@ impl<'a> Plot<'a> {
let mut labels = vec![];
for row in &rows {
let mut x = r.x0;
for (s, label, w) in row {
for (s, label, w, _) in row {
self.legend_swatch(*s, (x, y));
labels.push(self.legend_label((x, y), label));
x += w;
@@ -954,7 +958,7 @@ impl<'a> Plot<'a> {
y += ls * 1.8;
continue;
}
y += ls * 1.8 * label.split('|').count() as f64;
y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label, self.b.d.setup.system_layout);
self.legend_swatch(s, (x, row_y));
labels.push(self.legend_label((x, row_y), &label));
}
+88 -7
View File
@@ -10,7 +10,7 @@ use super::{
pub(super) struct Gen<'a> {
pub(super) d: &'a mut Document,
pub(super) out: Vec<Arc<Node>>,
/// The labels' kept character styles.
/// The labels' kept character styles and paragraph attributes.
pub(super) styles: &'a vectorcraft_doc::LabelStyles,
/// What the column designs may still add.
pub(super) columns: Allowance,
@@ -45,6 +45,34 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) ->
st
}
/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the
/// alignment.
pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle {
let mut para = kept_para(styles, role).cloned().unwrap_or_default();
// A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label).
para.keep_spacing_in_range();
para
}
/// The paragraph attributes labels of `role` keep, if any.
fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> {
match role {
Role::Value => styles.values_para.as_ref(),
Role::Category => styles.categories_para.as_ref(),
Role::Legend => styles.legend_para.as_ref(),
}
}
/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp.
pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias {
let kept = match role {
Role::Value => styles.values_anti_alias,
Role::Category => styles.categories_anti_alias,
Role::Legend => styles.legend_anti_alias,
};
kept.unwrap_or_default()
}
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
label_style(styles, role).size
@@ -61,19 +89,37 @@ impl Gen<'_> {
self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5)
}
pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc<Node> {
let t = self.text_object(role, at, s, justify);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
/// Label `s` of `role` set at `at` and laid out, not yet in the document.
fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject {
let style = label_style(self.styles, role);
let mut t = TextObject::point(at, s, style);
t.para = label_para(self.styles, role);
t.anti_alias = label_anti_alias(self.styles, role);
t.para.justify = justify;
t.system_layout = self.d.setup.system_layout;
refresh_bounds(&mut t);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
t
}
/// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred
/// on `at` (labels beside an axis or in a wedge) instead of running down from it.
pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc<Node> {
let lines = s.split('|').count();
let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at };
self.text(role, at, &s.replace('|', "\n"), justify)
// Default leading puts the first and last baselines this far above and below `at`.
let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0;
let start = if middle { Point::new(at.x, at.y - half) } else { at };
let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify);
// Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An
// empty first or last line has no baseline, and such a label stays where the default leading puts it.
if middle && kept_para(self.styles, role).is_some() {
let ys: Vec<f64> = t.baselines().map(|(a, _)| a.y).collect();
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) {
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
}
}
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
pub(super) fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
@@ -162,12 +208,47 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc<Node> {
Arc::new(d.reid(&c))
}
/// How wide label `s` is set (its widest line), without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
/// The bounds of legend label `s` set from the origin with paragraph attributes `para` and the document's System
/// Layout setting `system_layout`, as [`Gen::text`] sets it, without adding it to a document.
fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle, system_layout: bool) -> Option<Rect> {
let style = label_style(styles, Role::Legend);
let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style);
t.para = para;
t.para.justify = Justify::Left;
t.system_layout = system_layout;
refresh_bounds(&mut t);
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width())
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds()
}
/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right
/// by [`label_shift`], without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 {
legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)
.map_or(0.0, |b| (b.x1 + label_shift(styles, s, system_layout).0).max(0.0))
}
/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones.
fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> Option<(Rect, Rect)> {
styles.legend_para.as_ref()?;
Some((
legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)?,
legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default(), system_layout)?,
))
}
/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the
/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays
/// clear of its swatch and of the entries before it.
pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> (f64, f64) {
kept_and_default_bounds(styles, s, system_layout)
.map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0)))
}
/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more
/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room
/// for it.
pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 {
kept_and_default_bounds(styles, s, system_layout).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0))
}
/// A label with nothing to show (empty, or only `|` line breaks).
+59 -12
View File
@@ -1,7 +1,7 @@
use vectorcraft_color::BlendMode;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_style};
use super::labels::{Role, filled_lines_on, label_para, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
// ---------- generation ----------
@@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
out
}
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
/// they undo together. Returns whether a kept style changed.
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
if ids.is_empty() {
return false;
}
/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as
/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed.
pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool {
let labels = graph_labels(doc);
if labels.is_empty() {
return false;
}
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
kinds.dedup();
let mut changed = false;
for (graph, role) in kinds {
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 slot = match role {
Role::Value => &mut spec.label_styles.values_anti_alias,
Role::Category => &mut spec.label_styles.categories_anti_alias,
Role::Legend => &mut spec.label_styles.legend_anti_alias,
};
changed |= *slot != Some(method);
*slot = Some(method);
}
changed
}
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
/// they undo together. Returns whether a kept style changed.
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
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 mut style = label_style(&spec.label_styles, role);
change(&mut style);
@@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
changed
}
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
pub(crate) fn capture_label_paragraphs(
doc: &mut Document,
ids: &[NodeId],
change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>,
) -> Result<bool> {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
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 mut para = label_para(&spec.label_styles, role);
change(&mut para)?;
let slot = match role {
Role::Value => &mut spec.label_styles.values_para,
Role::Category => &mut spec.label_styles.categories_para,
Role::Legend => &mut spec.label_styles.legend_para,
};
changed |= slot.as_ref() != Some(&para);
*slot = Some(para);
}
Ok(changed)
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
return vec![];
}
let labels = graph_labels(doc);
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
kinds.dedup();
kinds
}
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
+252
View File
@@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() {
assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default());
}
/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn
/// again, through save and open, and undoes with the change.
#[test]
fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() {
use vectorcraft_doc::text::TypeAntiAlias;
let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for g in ["Axes", "Legend"] {
for c in group(&n, g).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
out.push((t.plain_text(), t.anti_alias));
}
}
}
out
};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap();
label_looks(&mut s, id);
let drawn = methods(&s, id);
let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap();
assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}");
assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}");
// One undo takes the data change back, the next the method and the kept method together.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None);
s.execute("edit.redo", &json!({})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
}
/// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it.
#[test]
fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() {
@@ -4219,3 +4258,216 @@ fn csv_round_trip_keeps_blank_row_in_single_column_graph() {
assert!(c.is_empty());
assert_eq!(r, g.cells());
}
/// Each generated label's text, paragraph attributes and alignment.
fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for name in ["Axes", "Legend"] {
for c in group(&n, name).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
out.push((t.plain_text(), t.para.clone()));
}
}
}
out
}
#[test]
fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() {
use vectorcraft_doc::{Composer, ParaDirection, text::Kashida};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]}))
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let paras = label_paras(&s, id);
assert!(!paras.is_empty());
for (text, p) in &paras {
assert_eq!(p.composer, Composer::SingleLine, "{text}");
assert_eq!(p.kashida, Kashida::Long, "{text}");
assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}");
assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}");
}
// The graph still aligns each label to its place.
assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::<Vec<_>>(), aligned);
// One kind of label: only that kind keeps it, also through save and open.
let col = graph(&mut s, "column");
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let paras = label_paras(&s, col);
let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer;
assert_eq!(composer("0"), Composer::SingleLine, "every value axis number");
assert_eq!(composer("Q1"), Composer::default(), "category labels as they were");
assert_eq!(composer("2024"), Composer::default(), "legend labels as they were");
// Part of a label's text: nothing kept.
let q = text_id(&s, col, "Axes", "Q2");
s.execute("select.set", &json!({"ids": [q.0]})).unwrap();
s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap();
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default()));
// Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label.
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap();
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap();
assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err());
let kept = spec(&s, col).label_styles.values_para.unwrap();
assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]);
}
#[test]
fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let l = text_id(&s, id, "Legend", "2024");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap();
let label = label.geometric_bounds().unwrap();
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}");
}
#[test]
fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let middle = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap();
let b = label.geometric_bounds().unwrap();
(b.y0 + b.y1) / 2.0
};
let before = middle(&s);
let l = text_id(&s, id, "Axes", "Q\n1");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let after = middle(&s);
assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_legend_across_top_measures_its_labels_with_system_layout() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
s.execute("type.systemLayout", &json!({"on": true})).unwrap();
// Each `i` at 2 pt advances a whole point with System Layout, far more than its own width.
let name = "i".repeat(60);
let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6");
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let l = text_id(&s, id, "Legend", &name);
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 2})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap();
let label = label.geometric_bounds().unwrap();
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}");
}
#[test]
fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() {
for across_top in [true, false] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap();
let l = text_id(&s, id, "Legend", "2025");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label =
group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, label) = (bounds("2024"), bounds("2025"));
let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}");
if across_top {
assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}");
}
}
}
#[test]
fn a_legend_makes_room_for_the_kept_leading_of_its_labels() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let l = text_id(&s, id, "Legend", "20\n24");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, next) = (bounds("20\n24"), bounds("2025"));
assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}");
}
#[test]
fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let l = text_id(&s, id, "Legend", "20\n25");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, next) = (bounds("2024"), bounds("20\n25"));
assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}");
}
File diff suppressed because one or more lines are too long
@@ -152,6 +152,7 @@ pub(super) const ALLOWED: &[&str] = &[
"edit.findNext",
"edit.findReplace",
"effect.apply",
"effect.deferredStatus",
"effect.duplicate",
"effect.expandAppearance",
"effect.list",
@@ -662,11 +663,13 @@ pub(super) const ALLOWED: &[&str] = &[
];
/// Commands a plug-in may not call although no rule refuses them, in order: they change the
/// preferences, a tool's options, a drag's state or the font names the whole app knows.
/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run
/// plug-ins again.
pub(super) const DENIED: &[&str] = &[
"addon.cleanup.splitText",
"addon.pathtools.movePointsTogether",
"addon.pen.nextSegment",
"effect.refreshDeferred",
"graphicStyle.setOptions",
"object.blend.options",
"object.liquify",
+10
View File
@@ -1321,6 +1321,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if held == choice && (choice.is_none() || alone) {
// The text is left as it is, so the glyph is the one the unchanged text draws. The probe
// splits a run that holds an earlier character's choice, and the split can break a ligature
// that takes in this character.
let drawn = match &choice {
None if probe.runs != t.runs => {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
drawn_glyph(&plain, &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default)
}
_ => drawn,
};
return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn}));
}
s.edit("Glyphs", |d, _| {
+4
View File
@@ -110,6 +110,9 @@ pub struct DocState {
/// paths the document links to are looked at within them later too, when the Links panel
/// asks about a link outside any caller's scope.
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
/// The live-effect plug-in stages deferred while the document is drawn on screen, and the time
/// budget of its first view ([`vectorcraft_plugins::deferred`]). Not saved.
pub plugin_effects: vectorcraft_plugins::deferred::Deferrals,
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
empty_artboards: std::cell::RefCell<EmptyArtboards>,
/// What [`DocState::any_art_to_fit`] last measured. Not saved.
@@ -326,6 +329,7 @@ impl DocState {
last_perspective: None,
opened_by: file_access::current(),
empty_artboards: Default::default(),
plugin_effects: Default::default(),
art_to_fit: Default::default(),
}
}
+25
View File
@@ -350,6 +350,31 @@ fn gradients_rasterize_and_flat_areas_stay_vector() {
}
}
#[test]
fn a_long_rasterized_area_is_placed_whole() {
let _settings = settings_lock();
let mut s = session();
// 5569 pt long: more than the renderer's 32,768 pixels a side at the 64 Mpx limit's scale.
let g = rect(&mut s, 10.0, 10.0, 100.0, 5569.0, "#000000");
run(
&mut s,
"paint.setFill",
json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
);
run(&mut s, "transparency.set", json!({"ids": [g], "opacity": 40}));
let (ids, _, rasterized) = flatten(&mut s, json!({"ids": [g], "lineArtPpi": 1200, "gradientPpi": 1200}));
assert_eq!(rasterized, 1);
let mut placed = vec![];
node(&s, ids[0]).walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
placed.push((im.height, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))));
}
});
let [(height, b)] = placed[..] else { panic!("one image: {placed:?}") };
assert!(height <= vectorcraft_render::MAX_RASTER_SIDE, "{height} pixels");
assert!(b.y0 <= 10.0 && b.y1 >= 5579.0, "the image covers the strip: {b:?}");
}
#[test]
fn flatten_says_when_an_image_could_not_be_decoded() {
let _settings = settings_lock();
+168
View File
@@ -980,6 +980,174 @@ fn painted_pieces_keep_to_the_effects_budget() {
assert!((350..=100_000).contains(&paths), "{paths} paths");
}
/// A clock for a document's deferrals that reads `step` milliseconds later each time it is read;
/// the test changes the step as it goes.
fn stepping_clock(step: &std::sync::Arc<std::sync::atomic::AtomicU64>) -> vectorcraft_plugins::deferred::Clock {
let (now, step) = (std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), step.clone());
std::sync::Arc::new(move || Some(now.fetch_add(step.load(std::sync::atomic::Ordering::Relaxed), std::sync::atomic::Ordering::Relaxed)))
}
/// While a document is drawn on screen, the plug-in effects of its first view share a time budget.
/// A stage left out draws its input art, is listed by `effect.deferredStatus`, and stays left out
/// on later drawings; Effect › Refresh Deferred Effects runs it again with a fresh budget, and an
/// export draws every stage. Another document has a budget of its own.
#[test]
fn the_first_view_defers_plug_in_effects_past_its_budget() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 30.0, "#ff0000");
for (id, x) in [("test.engine.deferred.a", 100.0), ("test.engine.deferred.b", 140.0)] {
let out = json!({"objects": [{"path": square_at(x)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest(id, "effect", "{}"), &out));
}
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.a"}));
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "plugin.test.engine.deferred.b"}));
// Each reading is 3 s later: the first run's result comes back after the budget, and the
// second run doesn't start.
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
let img = fx.view(|| drawn(&s));
fx.first_view_done();
assert_eq!((img.pixel(15, 15), img.pixel(35, 15), img.pixel(110, 50), img.pixel(150, 50)), (RED, RED, WHITE, WHITE), "the input art");
assert_eq!(fx.take_report(), Some(2));
let status = run(&mut s, "effect.deferredStatus", json!({}));
let listed = |id: NodeId, plugin: &str| json!({"objectId": id.0, "effectIndex": 0, "pluginId": plugin, "reason": "budget"});
assert_eq!(status, json!({"budgetMs": 2000, "effects": [listed(a, "test.engine.deferred.a"), listed(b, "test.engine.deferred.b")]}));
assert_eq!(fx.view(|| drawn(&s)).pixel(110, 50), WHITE, "a later drawing leaves it deferred");
// Refreshed with a fresh budget, in which the clock stands still.
assert!(s.execute("effect.refreshDeferred", &json!({"ids": ["a"]})).is_err());
assert!(s.execute("effect.refreshDeferred", &json!({"ids": [999_999]})).is_err(), "not an object of the document");
step.store(0, std::sync::atomic::Ordering::Relaxed);
let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0]}));
assert_eq!(refreshed, json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
let img = fx.view(|| drawn(&s));
assert_eq!((img.pixel(110, 50), img.pixel(150, 50)), (RED, WHITE));
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([listed(b, "test.engine.deferred.b")]));
// An export draws every stage; the view then uses the result.
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.contains(r#"<rect x="140" y="40" width="20" height="20""#), "the square at 140: {svg}");
assert_eq!(fx.view(|| drawn(&s)).pixel(150, 50), RED);
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred");
// Another document: its first view has a budget of its own.
let mut other = session();
let c = rect(&mut other, 50.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(170.0)}]}).to_string();
install_wat(&mut other, &tpl::returning(&tpl::manifest("test.engine.deferred.c", "effect", "{}"), &out));
run(&mut other, "effect.apply", json!({"ids": [c.0], "effect": "plugin.test.engine.deferred.c"}));
let still = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let fx_c = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&still));
other.doc_mut().unwrap().plugin_effects = fx_c.clone();
assert_eq!(fx_c.view(|| drawn(&other)).pixel(180, 50), RED);
fx_c.first_view_done();
assert_eq!(fx_c.take_report(), Some(0));
}
/// Deferred stages are listed where their effects are: after an effect that changes no geometry,
/// and on a fill after a Transform effect that reaches its appearance, each of two instances of
/// one plug-in at its own place.
#[test]
fn deferred_stages_are_listed_at_their_effects_places() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 60.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
// An effect that paints its pieces: the object is drawn from its reshaped art.
install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.at", true), &out));
const FX: &str = "plugin.test.engine.deferred.at";
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "stylize.dropShadow"}));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": FX}));
}
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "distort.transform", "params": {"strokes": true}, "item": 0}));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": FX, "item": 0}));
}
// No run has time to give a result.
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
let places = run(&mut s, "effect.deferredStatus", json!({}))["effects"]
.as_array()
.unwrap()
.iter()
.map(|e| (e["objectId"].as_u64().unwrap(), e["item"].as_u64(), e["effectIndex"].as_u64().unwrap()))
.collect::<Vec<_>>();
assert_eq!(places, [(a.0, None, 1), (a.0, None, 2), (b.0, Some(0), 1), (b.0, Some(0), 2)]);
}
/// A refresh that does not draw a stage (its object, or a group holding it, is hidden) leaves it
/// deferred, and a stage whose effect has been removed or hidden, or whose object has been removed,
/// is no longer listed and does not enable Effect › Refresh Deferred Effects.
#[test]
fn refresh_keeps_stages_it_did_not_draw_and_drops_removed_ones() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 30.0, "#ff0000");
let c = rect(&mut s, 50.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.kept", "effect", "{}"), &out));
run(&mut s, "effect.apply", json!({"ids": [a.0, b.0, c.0], "effect": "plugin.test.engine.deferred.kept"}));
run(&mut s, "select.set", json!({"ids": [c.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
fx.first_view_done();
let listed = |s: &mut Session| {
run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>()
};
assert_eq!(listed(&mut s), [a.0, b.0, c.0]);
s.execute("object.hide", &json!({"ids": [a.0, g.0]})).unwrap();
step.store(0, std::sync::atomic::Ordering::Relaxed);
let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0, c.0]}));
assert_eq!(refreshed, json!({"refreshed": 0, "stillDeferred": 2, "failed": 0}), "a hidden object is not drawn");
assert_eq!(listed(&mut s), [a.0, b.0, c.0]);
run(&mut s, "effect.remove", json!({"ids": [a.0], "index": 0}));
assert_eq!(listed(&mut s), [b.0, c.0], "its effect was removed");
run(&mut s, "effect.setParams", json!({"ids": [b.0], "index": 0, "visible": false}));
assert_eq!(listed(&mut s), [c.0], "its effect was hidden");
s.execute("edit.clear", &json!({"ids": [b.0, g.0]})).unwrap();
assert_eq!(listed(&mut s), Vec::<u64>::new(), "its object was deleted");
assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred");
}
/// A deferred stage in a symbol's art is listed by the object in that art, and a refresh runs it
/// where the symbol is placed.
#[test]
fn deferred_stages_in_symbol_art_are_listed_and_refreshed() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.symbol", "effect", "{}"), &out));
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.symbol"}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
let instance = run(&mut s, "symbol.new", json!({"name": "Deferred"}))["id"].as_u64().unwrap();
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
fx.first_view_done();
assert_eq!(fx.take_report(), Some(1));
let effects = run(&mut s, "effect.deferredStatus", json!({}))["effects"].clone();
let object = effects[0]["objectId"].as_u64().unwrap();
assert_eq!(effects.as_array().map(Vec::len), Some(1), "{effects}");
assert!(object != instance && s.doc().unwrap().doc.node(NodeId(object)).is_none(), "an object of the symbol's art: {effects}");
step.store(0, std::sync::atomic::Ordering::Relaxed);
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
+11 -1
View File
@@ -41,8 +41,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
let mut s = session();
let list = run(&mut s, "graphicStyle.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert!(libs.len() >= 7 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}");
assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}");
assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}");
assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}");
for l in libs {
let id = l["id"].as_str().unwrap();
let g = run(&mut s, "graphicStyle.library", json!({"library": id}));
@@ -60,6 +61,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
let game = run(&mut s, "graphicStyle.library", json!({"library": "game-hud"}));
let game_names: Vec<&str> = game["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(game_names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]);
let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"}));
let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]);
let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]}));
assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow")));
@@ -67,6 +71,12 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#3498db").unwrap()));
assert_eq!(node.appearance.stroke_paint(), Paint::None);
assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow"]);
let id2 = rect(&mut s, 60.0, 20.0, 40.0, 30.0);
let r2 = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "status-effects", "name": "Poison Tint", "apply": true, "ids": [id2.0]}));
assert_eq!((names(&r2["added"]), r2["applied"].as_str()), (vec!["Poison Tint"], Some("Poison Tint")));
let node2 = doc(&s).node(id2).unwrap();
assert_eq!(node2.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#2ecc71").unwrap()));
assert_eq!(node2.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow"]);
// By name in any case.
assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows");
assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err());
+52
View File
@@ -207,6 +207,58 @@ fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() {
assert_eq!(drawn(&s, a), json!(SHIFT_MORE));
}
/// The `(name, xml)` of every `svg.filter` effect on the document's objects.
fn filter_effects(s: &Session) -> Vec<(String, String)> {
let mut out = vec![];
s.doc().unwrap().doc.walk(|n| {
for e in n.appearance.effects.iter().filter(|e| e.id == vectorcraft_render::effects::SVG_FILTER) {
out.push((e.params["name"].as_str().unwrap().to_string(), e.params["xml"].as_str().unwrap().to_string()));
}
});
out
}
/// Pasted art whose filter has a name the document gives another filter keeps its own filter
/// under a free name, so editing or deleting the document's filter leaves it alone. Pasted back
/// into the document it came from, it takes the document's filter as it is now.
#[test]
fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_eq!(pasted[0].0, "Shift 2", "renamed: {pasted:?}");
assert!(pasted[0].1.contains("id=\"Shift 2\"") && pasted[0].1.contains("dx=\"9\""), "its own filter: {pasted:?}");
assert_eq!(names(&mut s), ["Shift"]);
run(&mut s, "svgFilter.delete", json!({"name": "Shift"}));
assert_eq!(filter_effects(&s), pasted, "the document's filter leaves it alone");
// The same filter as the document's keeps its name.
let mut t = session();
run(&mut t, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut t, "clipboard.importSvg", json!({"svg": svg}));
run(&mut t, "edit.pasteInPlace", json!({}));
assert_eq!(filter_effects(&t), [("Shift".to_string(), SHIFT.to_string())]);
// Copied, then the filter edited: pasted back, the copy draws the edited filter.
let mut u = session();
let a = rect(&mut u, 10.0);
run(&mut u, "svgFilter.define", json!({"xml": SHIFT}));
run(&mut u, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
run(&mut u, "select.set", json!({"ids": [a.0]}));
run(&mut u, "edit.copy", json!({}));
run(&mut u, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"}));
run(&mut u, "edit.pasteInPlace", json!({}));
let both = filter_effects(&u);
assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]);
}
/// Art in a symbol's definition that uses a filter follows its edits too.
#[test]
fn a_symbols_art_follows_a_filter_edit() {
+25
View File
@@ -3511,6 +3511,31 @@ fn a_glyph_choice_taken_away_reports_the_glyph_drawn() {
assert_eq!(chosen(&mut s, caps, 1), json!(drawn));
}
/// A run that holds a choice for its first character and goes on past it, as a file may have,
/// draws Source Serif 4's fi ligature for the f and the i after that character. `gid: null` for the
/// i leaves the text as it is and reports the ligature, as `text.glyphAlternates` does.
#[test]
fn a_glyph_choice_taken_away_inside_a_ligature_reports_the_ligature() {
let db = vectorcraft_text::FontDb::global();
let face = db.face("Source Serif 4", "Regular").expect("the bundled face");
let mut s = session();
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xfin", "size": 40, "font": "Source Serif 4"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let mut runs = obj(&s, id).runs;
runs[0].style.glyph_alternate = Some(face.choice('x', face.glyph_for('x')));
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(text)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("text") };
text.runs = runs.clone();
let ligature = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.iter().find(|g| g.byte == 1 && g.len == 2).expect("f + i is one glyph").gid;
let chosen = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap()["chosen"].clone();
assert_eq!(chosen, json!(ligature));
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": null})).unwrap();
assert_eq!(r["gid"], chosen, "the ligature drawn for the i");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no edit");
assert_eq!(obj(&s, id).runs, runs, "the runs are kept");
}
/// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph
/// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel
/// sign's.
+9 -3
View File
@@ -708,6 +708,14 @@ impl Session {
Ok(())
}
/// The artboard a guide made from a ruler belongs to: with the Artboard tool, its active
/// artboard (an artboard guide); with any other tool, none (a canvas guide).
pub fn ruler_guide_artboard(&self) -> Option<usize> {
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten();
active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards)
}
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
/// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the
/// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over
@@ -722,11 +730,9 @@ impl Session {
let g = match self.ruler_guide.take() {
Some(g) => Some(g),
None => {
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
let artboard_tool = self.tool.id() == "artboard";
let swap = self.prefs.cmd_ruler_drag_artboard_guides && ev.mods.cmd;
let active = artboard_tool.then(|| self.tool.options()["active"].as_u64()).flatten();
let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards);
let artboard = self.ruler_guide_artboard();
let view_board = view.artboard;
self.with_tool_cx(view, |_, cx| {
Some(match (swap, artboard_tool) {
+724
View File
@@ -0,0 +1,724 @@
//! Live-effect plug-ins while a document opens: the plug-in effects drawn for its first view share
//! a time budget of [`OPENING_BUDGET_MS`], and a stage that is not run for want of time, or that
//! gives no result, is deferred: it draws its input art and is listed with the reason
//! ([`Deferrals::list`]) until it runs again.
//!
//! One [`Deferrals`] belongs to each open document. Drawing that document on screen runs inside
//! [`Deferrals::view`], which applies its budget and its deferred stages to the plug-in effects
//! evaluated on that thread ([`crate::effect`]):
//!
//! - until [`Deferrals::first_view_done`], every run takes the lesser of its per-call limit and
//! what is left of the budget, no run starts once the budget is spent, and a result that comes
//! back after the budget ran out is not used;
//! - afterwards a deferred stage is not run again while its input and parameters stay the same; a
//! stage whose input or parameters changed runs under the per-call limit;
//! - [`Deferrals::refresh`] runs deferred stages again with a fresh budget.
//!
//! Work outside a view (exports, commands, drawing without a screen) runs every stage under the
//! per-call limit. When such a run gives a result for a stage some document has deferred,
//! [`revision`] changes, so that drawings made with the deferred stage are drawn again.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
/// The time the plug-in effects drawn for a document's first view share, in milliseconds.
pub const OPENING_BUDGET_MS: u64 = 2_000;
/// Why a stage is deferred.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Reason {
/// It was not run, or its result came too late, because the budget was spent.
Budget,
/// It ran to its per-call time limit.
Timeout,
/// Its plug-in isn't installed as a live effect.
Missing,
/// It trapped, panicked, returned art that was refused, or its parameters were refused.
Failed,
/// Safe Mode is on: no plug-in runs.
SafeMode,
}
impl Reason {
/// The reason as `effect.deferredStatus` names it.
pub fn name(self) -> &'static str {
match self {
Reason::Budget => "budget",
Reason::Timeout => "timeout",
Reason::Missing => "missing",
Reason::Failed => "failed",
Reason::SafeMode => "safeMode",
}
}
}
/// Where a plug-in effect sits: the object, the index of the effect in its list, and the fill or
/// stroke whose list that is (`None`: the object's own effects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Stage {
pub object: u64,
pub effect: usize,
pub item: Option<usize>,
}
/// A deferred stage.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Deferred {
pub stage: Stage,
/// The plug-in's id.
pub plugin: String,
pub reason: Reason,
/// The input and parameters the stage was deferred with (the effect cache's key), 0 when the
/// plug-in was not looked at (Safe Mode, a missing plug-in).
pub(crate) key: u64,
}
/// What [`Deferrals::refresh`] did with the stages it ran again.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Refreshed {
/// Stages that gave a result.
pub refreshed: usize,
/// Stages left deferred for want of time, or with input the refresh did not run.
pub still_deferred: usize,
/// Stages that ran out of their per-call time, failed or had no plug-in again.
pub failed: usize,
}
/// Milliseconds on a monotonic clock, `None` where there is none (the web build).
pub type Clock = Arc<dyn Fn() -> Option<u64> + Send + Sync>;
/// Milliseconds since the first reading in this process (native builds); `None` on the web build,
/// whose `std` has no monotonic clock.
fn monotonic() -> Option<u64> {
#[cfg(not(target_arch = "wasm32"))]
{
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let start = START.get_or_init(std::time::Instant::now);
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
}
#[cfg(target_arch = "wasm32")]
None
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Phase {
/// Runs share a budget that started at `started` (`None`: no run yet). `first`: the first
/// view, which reports what it deferred when it ends.
Budgeted { started: Option<u64>, first: bool },
/// Deferred stages are left as they are while their input stays the same.
Open,
}
struct State {
phase: Phase,
deferred: Vec<Deferred>,
/// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it.
report: Option<usize>,
/// The refresh running, if one is ([`Deferrals::refresh`]).
refresh: Option<Refresh>,
clock: Clock,
}
/// A [`Deferrals::refresh`] while it runs. It applies only to the thread that runs it: drawings
/// of the document on other threads go on as if no refresh ran.
struct Refresh {
thread: std::thread::ThreadId,
/// When its budget started (`None`: no run yet).
started: Option<u64>,
/// The objects whose stages run again; the others' stay deferred.
ids: Vec<u64>,
/// The cache keys whose cached failures have been run again.
retried: Vec<u64>,
/// The stages of `ids` that were drawn.
visited: Vec<Stage>,
/// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again.
touched: Vec<(Stage, u64)>,
}
impl State {
/// The refresh running on this thread.
fn refresh_here(&mut self) -> Option<&mut Refresh> {
let here = std::thread::current().id();
self.refresh.as_mut().filter(|r| r.thread == here)
}
/// The phase that applies on this thread: a refresh running here has a budget of its own.
fn phase_here(&mut self) -> Phase {
match self.refresh_here() {
Some(r) => Phase::Budgeted { started: r.started, first: false },
None => self.phase,
}
}
/// Is a refresh running here, and does it run the stages of `object`?
fn refreshes(&mut self, object: u64) -> Option<bool> {
self.refresh_here().map(|r| r.ids.contains(&object))
}
}
impl Drop for State {
fn drop(&mut self) {
for d in &self.deferred {
unmark(d.key);
}
}
}
/// The deferred stages and the opening budget of one open document. Clones share them.
#[derive(Clone)]
pub struct Deferrals(Arc<Mutex<State>>);
impl Default for Deferrals {
fn default() -> Self {
Self::with_clock(Arc::new(monotonic))
}
}
impl std::fmt::Debug for Deferrals {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Deferrals").field("deferred", &self.list()).finish()
}
}
/// What a budgeted run may take.
pub(crate) enum Allowance {
/// No budget applies: the per-call limit.
Unlimited,
/// At most this many milliseconds, which end the budget at `until`.
Until { ms: u64, until: u64 },
/// The budget is spent: the stage is deferred without running.
Spent,
}
impl Deferrals {
/// A document's deferrals before its first view, timed by the monotonic clock.
pub fn new() -> Self {
Self::default()
}
/// [`Deferrals::new`] timed by `clock` (tests give a clock of their own).
pub fn with_clock(clock: Clock) -> Self {
Self(Arc::new(Mutex::new(State {
phase: Phase::Budgeted { started: None, first: true },
deferred: Vec::new(),
report: None,
refresh: None,
clock,
})))
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The deferred stages, by object and effect, each once (a stage drawn with several inputs,
/// such as the paths of a group, is listed with the first one's reason).
pub fn list(&self) -> Vec<Deferred> {
let mut out = self.state().deferred.clone();
out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect));
out.dedup_by_key(|d| d.stage);
out
}
/// Is the first view still being drawn ([`Deferrals::first_view_done`] not called yet)?
pub fn opening(&self) -> bool {
matches!(self.state().phase, Phase::Budgeted { first: true, .. })
}
/// Runs `f` with these deferrals applied to the plug-in effects evaluated on this thread.
pub fn view<T>(&self, f: impl FnOnce() -> T) -> T {
let previous = VIEW.with(|v| v.borrow_mut().replace(self.clone()));
let _ = PASSES.try_with(|p| p.borrow_mut().push(Pass::default()));
let _restore = Restore { previous, current: self.clone() };
f()
}
/// One drawing inside [`Deferrals::view`] has ended: a stage that ran after the first view,
/// whether it gave a result or was deferred again, keeps only the records of the inputs that
/// drawing left deferred. The records of its other inputs were made with input it no longer
/// has.
fn settle(&self, pass: Pass) {
if pass.ran.is_empty() {
return;
}
self.state().deferred.retain(|d| {
let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key));
if !keep {
unmark(d.key);
}
keep
});
}
/// The first view has been drawn: later drawings leave deferred stages deferred, and
/// [`Deferrals::take_report`] gives how many there are. Does nothing after the first call.
pub fn first_view_done(&self) {
let mut s = self.state();
if let Phase::Budgeted { first: true, .. } = s.phase {
s.phase = Phase::Open;
s.report = Some(s.stages().len());
}
}
/// How many stages the first view left deferred, once, after it was drawn.
pub fn take_report(&self) -> Option<usize> {
self.state().report.take()
}
/// Runs the deferred stages of the objects in `objects` (all of them when `None`) again with a
/// fresh budget: `run` is given those objects, draws them inside [`Deferrals::view`] on this
/// thread, and returns the objects it drew whole, every part of them reached. A stage the
/// drawing does not reach (that of a hidden object, say) stays deferred as it was, and so does
/// a stage input it does not reach, unless the object was drawn whole: the input is then one
/// the stage no longer has. The refresh and its budget apply only to this thread.
pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec<u64>) -> Refreshed {
let (targets, ids) = {
let mut s = self.state();
let mut targets: Vec<Stage> = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect();
targets.sort_by_key(|t| (t.object, t.item, t.effect));
targets.dedup();
let mut ids: Vec<u64> = targets.iter().map(|t| t.object).collect();
ids.dedup();
let thread = std::thread::current().id();
let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new());
s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched });
(targets, ids)
};
// The refresh ends when `run` returns, also when it unwinds.
struct Reopen<'a>(&'a Deferrals);
impl Drop for Reopen<'_> {
fn drop(&mut self) {
self.0.state().refresh = None;
}
}
let reopen = Reopen(self);
let whole = run(&ids);
let mut s = self.state();
let touched = match s.refresh.take() {
Some(r) => {
s.deferred.retain(|d| {
let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key));
if gone {
unmark(d.key);
}
!gone
});
r.touched
}
None => Vec::new(),
};
drop(s);
drop(reopen);
// A stage fails again when an input it ran with this time is deferred for a reason other
// than time; one with records left that this refresh did not run is still deferred.
let s = self.state();
let mut out = Refreshed::default();
for t in &targets {
let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable();
if left.peek().is_none() {
out.refreshed += 1;
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) {
out.failed += 1;
} else {
out.still_deferred += 1;
}
}
drop(s);
if out.refreshed > 0 {
REVISION.fetch_add(1, Ordering::Relaxed);
}
out
}
/// Records `stage` with cache key `key` (of plug-in `plugin`) as deferred for `reason`, in
/// place of what was recorded for that stage and key. After the first view, when the drawing
/// ends, the stage's records of inputs that drawing did not leave deferred are dropped
/// ([`Deferrals::settle`]).
pub(crate) fn defer(&self, stage: Stage, plugin: &str, reason: Reason, key: u64) {
seen(stage, key);
let mut s = self.state();
s.remove(stage, key);
mark(key);
s.deferred.push(Deferred { stage, plugin: plugin.to_string(), reason, key });
s.ran_here(stage, key);
}
/// `stage` gave a result for cache key `key`, from a run or from the cache: it is no longer
/// deferred with that input. After the first view, when the drawing ends, the stage's records
/// of inputs that drawing did not leave deferred are dropped as well ([`Deferrals::settle`]).
pub(crate) fn ran(&self, stage: Stage, key: u64) {
let mut s = self.state();
s.remove(stage, key);
s.ran_here(stage, key);
}
/// Should `stage`, with cache key `key`, be left deferred without running? After the first
/// view, a stage deferred with the same input and parameters is, and so is one of an object a
/// refresh does not run.
pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false);
let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key);
drop(s);
if held {
seen(stage, key);
}
held
}
/// `stage` is being drawn: a refresh running here that runs its object's stages notes that it
/// reached the stage.
pub(crate) fn visit(&self, stage: Stage) {
let mut s = self.state();
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) {
r.visited.push(stage);
}
}
/// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is
/// false, such as those of effects or objects removed from the document.
pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) {
self.state().deferred.retain(|d| {
let k = keep(d.stage, &d.plugin);
if !k {
unmark(d.key);
}
k
});
}
/// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key
/// while a refresh running here runs the stages of `stage`'s object.
pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false };
r.retried.push(key);
true
}
/// What a run starting now may take of the budget: the budget starts with the first run.
/// Without a clock, the first view runs nothing and a refresh runs every stage under its
/// per-call limit.
pub(crate) fn allowance(&self) -> Allowance {
let mut s = self.state();
let Phase::Budgeted { started, first } = s.phase_here() else { return Allowance::Unlimited };
let Some(now) = (s.clock)() else { return if first { Allowance::Spent } else { Allowance::Unlimited } };
let start = started.unwrap_or(now);
match s.refresh_here() {
Some(r) => r.started = Some(start),
None => s.phase = Phase::Budgeted { started: Some(start), first },
}
let until = start.saturating_add(OPENING_BUDGET_MS);
match until.checked_sub(now).filter(|ms| *ms > 0) {
Some(ms) => Allowance::Until { ms, until },
None => Allowance::Spent,
}
}
/// Has the clock passed `until` (a result that came back now is too late)? Without a clock,
/// yes.
pub(crate) fn passed(&self, until: u64) -> bool {
let clock = self.state().clock.clone();
clock().is_none_or(|now| now > until)
}
}
impl State {
/// Removes what is recorded for `stage` with cache key `key`. A record without a key (the
/// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0
/// removes every record of the stage.
fn remove(&mut self, stage: Stage, key: u64) {
self.deferred.retain(|d| {
let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0);
if !keep {
unmark(d.key);
}
keep
});
}
/// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this
/// thread: a refresh running here notes the input ([`Deferrals::refresh`]), and after the first
/// view [`Deferrals::settle`] looks at the stage when that drawing ends.
fn ran_here(&mut self, stage: Stage, key: u64) {
if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
r.touched.push((stage, key));
}
if self.phase_here() == Phase::Open {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) {
pass.ran.push(stage);
}
});
}
}
/// The deferred stages, each once.
fn stages(&self) -> Vec<Stage> {
let mut out: Vec<Stage> = self.deferred.iter().map(|d| d.stage).collect();
out.sort_by_key(|t| (t.object, t.item, t.effect));
out.dedup();
out
}
}
thread_local! {
static VIEW: RefCell<Option<Deferrals>> = const { RefCell::new(None) };
/// One entry for each [`Deferrals::view`] running on this thread, innermost last.
static PASSES: RefCell<Vec<Pass>> = const { RefCell::new(Vec::new()) };
}
/// What one drawing inside a view did: the stages that ran after the first view (with a result or
/// deferred again), and the stage inputs it left deferred.
#[derive(Default)]
struct Pass {
ran: Vec<Stage>,
seen: Vec<(Stage, u64)>,
}
/// The drawing on this thread left `stage` deferred with cache key `key`.
fn seen(stage: Stage, key: u64) {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut() {
pass.seen.push((stage, key));
}
});
}
/// Ends the drawing and puts the previous view back, also when the work inside unwinds.
struct Restore {
previous: Option<Deferrals>,
current: Deferrals,
}
impl Drop for Restore {
fn drop(&mut self) {
if let Some(pass) = PASSES.try_with(|p| p.borrow_mut().pop()).ok().flatten() {
self.current.settle(pass);
}
let previous = self.previous.take();
let _ = VIEW.try_with(|v| *v.borrow_mut() = previous);
}
}
/// The deferrals of the view being drawn on this thread ([`Deferrals::view`]).
pub fn current() -> Option<Deferrals> {
VIEW.try_with(|v| v.borrow().clone()).ok().flatten()
}
/// Changes when deferred stages give results: [`Deferrals::refresh`] ran some, or a run outside a
/// view gave a result for a stage some document has deferred.
pub fn revision() -> u64 {
REVISION.load(Ordering::Relaxed)
}
static REVISION: AtomicU64 = AtomicU64::new(0);
/// The cache keys some document's deferred stages were deferred with, and how many stages hold each.
static MARKED: Mutex<Option<HashMap<u64, usize>>> = Mutex::new(None);
fn mark(key: u64) {
if key != 0 {
*MARKED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).entry(key).or_default() += 1;
}
}
fn unmark(key: u64) {
let mut marked = MARKED.lock().unwrap_or_else(|e| e.into_inner());
let Some(m) = marked.as_mut() else { return };
if let Some(n) = m.get_mut(&key) {
*n -= 1;
if *n == 0 {
m.remove(&key);
}
}
}
/// A run gave a result for cache key `key`: when some document deferred a stage with that key,
/// [`revision`] changes.
pub(crate) fn resolved(key: u64) {
let marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key));
if marked {
REVISION.fetch_add(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A clock that reads `step` milliseconds later each time it is read.
fn stepping(step: u64) -> Clock {
let t = Arc::new(AtomicU64::new(0));
Arc::new(move || Some(t.fetch_add(step, Ordering::Relaxed)))
}
#[test]
fn the_budget_starts_with_the_first_run_and_ends_after_it() {
let d = Deferrals::with_clock(stepping(700));
assert!(matches!(d.allowance(), Allowance::Until { ms: 2_000, until: 2_000 }));
assert!(matches!(d.allowance(), Allowance::Until { ms: 1_300, until: 2_000 }));
assert!(matches!(d.allowance(), Allowance::Until { ms: 600, until: 2_000 }));
assert!(matches!(d.allowance(), Allowance::Spent));
d.first_view_done();
assert!(matches!(d.allowance(), Allowance::Unlimited));
}
#[test]
fn without_a_clock_the_first_view_runs_nothing_and_a_refresh_everything() {
let d = Deferrals::with_clock(Arc::new(|| None));
assert!(matches!(d.allowance(), Allowance::Spent));
assert!(d.passed(u64::MAX));
d.first_view_done();
d.defer(Stage::default(), "a", Reason::Budget, 31);
d.refresh(None, |_| {
assert!(matches!(d.allowance(), Allowance::Unlimited));
vec![]
});
}
/// A stage drawn with several inputs (the paths of a group) holds one record for each, and is
/// listed and counted once.
#[test]
fn a_stage_is_listed_and_counted_once() {
let d = Deferrals::with_clock(stepping(1));
let s = Stage { object: 7, effect: 1, item: None };
d.defer(s, "a", Reason::Budget, 11);
d.defer(s, "a", Reason::Budget, 12);
d.defer(s, "a", Reason::Timeout, 12);
d.defer(Stage { item: Some(0), ..s }, "a", Reason::Failed, 13);
assert_eq!(d.list().len(), 2);
assert!(!d.holds(s, 12), "the first view runs deferred stages");
d.first_view_done();
assert!(d.holds(s, 11) && d.holds(s, 12));
assert!(!d.holds(s, 14), "other input");
assert_eq!(d.take_report(), Some(2));
assert_eq!(d.take_report(), None);
d.first_view_done();
assert_eq!(d.take_report(), None, "the first view ends once");
d.ran(s, 11);
assert_eq!(d.list().len(), 2, "the stage is still deferred with its other input");
d.ran(s, 12);
assert_eq!(d.list().len(), 1);
d.defer(s, "a", Reason::Missing, 0);
d.ran(s, 15);
assert_eq!(d.list().len(), 1, "a record without a key goes with any result");
}
/// A refresh runs the deferred stages of the objects asked for with a fresh budget, and counts
/// what became of them; the stages its drawing did not reach stay as they were. Another
/// thread draws the document as if no refresh ran.
#[test]
fn a_refresh_counts_what_became_of_each_stage() {
let d = Deferrals::with_clock(stepping(1));
d.first_view_done();
let st = |object, effect| Stage { object, effect, item: None };
let stages = [
(st(1, 0), Reason::Budget),
(st(1, 1), Reason::Budget),
(st(1, 2), Reason::Budget),
(st(1, 3), Reason::Timeout),
(st(2, 0), Reason::Budget),
(st(3, 0), Reason::Failed),
];
for (stage, reason) in stages {
d.defer(stage, "a", reason, 40 + stage.object * 4 + stage.effect as u64);
}
let seen = std::cell::RefCell::new(vec![]);
let counts = d.refresh(Some(&[1, 3, 9]), |ids| {
seen.borrow_mut().extend_from_slice(ids);
assert!(matches!(d.allowance(), Allowance::Until { .. }), "a fresh budget");
assert!(d.holds(st(2, 0), 48), "an object not asked for stays deferred");
d.visit(st(2, 0));
d.visit(st(1, 0));
d.visit(st(1, 1));
d.defer(st(1, 1), "a", Reason::Budget, 45);
std::thread::scope(|scope| {
let other = scope.spawn(|| (matches!(d.allowance(), Allowance::Unlimited), d.holds(st(1, 2), 46), d.retries(st(3, 0), 52)));
assert_eq!(other.join().unwrap(), (true, true, false), "another thread");
});
d.visit(st(1, 1));
assert_eq!(d.list().iter().filter(|x| x.stage == st(1, 1)).count(), 1, "a stage is reached once");
d.visit(st(3, 0));
d.defer(st(3, 0), "a", Reason::Failed, 52);
ids.to_vec()
});
assert_eq!(*seen.borrow(), [1, 3]);
// st(1, 3) timed out before and was not reached: still deferred, not failed again.
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 3, failed: 1 });
assert!(matches!(d.allowance(), Allowance::Unlimited), "the budget ends with the refresh");
let left = d.list().iter().map(|x| x.stage).collect::<Vec<_>>();
assert_eq!(left, [st(1, 1), st(1, 2), st(1, 3), st(2, 0), st(3, 0)]);
// An object the drawing did not reach whole keeps the inputs it did not reach: a reached
// stage of it gives a result only for the inputs that ran.
d.defer(st(1, 1), "a", Reason::Budget, 70);
let counts = d.refresh(Some(&[1]), |_| {
d.visit(st(1, 1));
d.ran(st(1, 1), 45);
vec![]
});
assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 3, failed: 0 });
let keys = |stage| d.state().deferred.iter().filter(|x| x.stage == stage).map(|x| x.key).collect::<Vec<_>>();
assert_eq!(keys(st(1, 1)), [70]);
let counts = d.refresh(Some(&[1]), |_| {
d.visit(st(1, 1));
vec![1]
});
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 0 }, "drawn whole: an input it no longer has");
assert_eq!(keys(st(1, 1)), Vec::<u64>::new());
d.retain(|stage, _| stage.object != 1);
assert_eq!(d.list().iter().map(|x| x.stage).collect::<Vec<_>>(), [st(2, 0), st(3, 0)]);
}
/// After the first view, a stage drawn with several inputs (the paths of a group) whose one
/// changed input runs keeps the records of the inputs the same drawing left deferred, and drops
/// the record of the input it no longer has.
#[test]
fn a_stage_that_runs_keeps_the_inputs_its_drawing_left_deferred() {
let d = Deferrals::with_clock(stepping(1));
let s = Stage { object: 4, effect: 0, item: None };
let other = Stage { object: 5, ..s };
for key in [61, 62] {
d.defer(s, "a", Reason::Budget, key);
}
d.defer(other, "a", Reason::Budget, 63);
d.first_view_done();
d.view(|| {
assert!(d.holds(s, 61), "an unchanged path");
assert!(!d.holds(s, 64), "the changed path");
d.ran(s, 64);
});
let keys = d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::<Vec<_>>();
assert_eq!(keys, [(4, 61), (5, 63)]);
// A changed input that fails again replaces the record of the input it no longer has.
d.view(|| {
assert!(!d.holds(other, 65));
d.defer(other, "a", Reason::Failed, 65);
});
let left = d.state().deferred.iter().map(|x| (x.stage.object, x.key, x.reason)).collect::<Vec<_>>();
assert_eq!(left, [(4, 61, Reason::Budget), (5, 65, Reason::Failed)]);
}
#[test]
fn a_result_for_a_deferred_key_changes_the_revision() {
const KEY: u64 = 0xdef0_0001;
let marked = || MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&KEY));
let d = Deferrals::with_clock(stepping(1));
assert!(!marked());
d.defer(Stage::default(), "a", Reason::Budget, KEY);
let other = d.clone();
other.defer(Stage { object: 2, ..Stage::default() }, "a", Reason::Budget, KEY);
assert!(marked());
let before = revision();
resolved(KEY);
assert!(revision() > before);
other.ran(Stage::default(), KEY);
assert!(marked(), "another stage holds the key");
drop((d, other));
assert!(!marked(), "the document closed");
}
}
+256 -23
View File
@@ -12,7 +12,8 @@
//! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when
//! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and
//! reported by [`last_error`]. The swatches and patterns a painted result names are checked
//! against the document each time it is used.
//! against the document each time it is used. While a document is drawn on screen, its first view
//! shares a time budget and stages without a result are deferred ([`crate::deferred`]).
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
@@ -22,6 +23,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem, Document};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::deferred::{Allowance, Reason, Stage};
use crate::objects::{decode, output_geometry, paint_lists};
use crate::painted::{self, Piece, Scope};
use crate::{Error, Kind, Limits, Result, registry};
@@ -69,6 +71,9 @@ pub struct Paints<'a> {
/// The index in `appearance.items` of the fill or stroke whose own effects hold this one;
/// `None`: one of the object's effects.
pub item: Option<usize>,
/// The object and the effect's place in its list, which a deferred stage is listed by
/// ([`crate::deferred`]); `None`: not known, listed as object 0.
pub at: Option<Stage>,
}
/// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and
@@ -151,7 +156,8 @@ impl Out {
#[derive(Default)]
struct Cache {
entries: HashMap<u64, (Key, Option<Out>)>,
/// Each result, or why the run gave none ([`Reason::Timeout`] or [`Reason::Failed`]).
entries: HashMap<u64, (Key, std::result::Result<Out, Reason>)>,
/// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]).
anchors: usize,
}
@@ -294,9 +300,10 @@ pub fn paints_output(id: &str) -> bool {
/// For an effect that returns painted pieces, the geometry is every piece's path in order, and
/// `None` when the swatches and patterns they name are refused as [`apply_painted`] refuses them.
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option<PathData> {
let at = paints.at;
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
if let Out::Pieces(pieces) = &out {
references_found(id, &plugin, pieces, doc)?;
references_found(id, &plugin, pieces, doc, at)?;
}
Some(out.geometry())
}
@@ -318,9 +325,10 @@ pub fn apply_painted(
paints: Paints,
doc: Option<&Document>,
) -> Option<Arc<Vec<Piece>>> {
let at = paints.at;
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
let Out::Pieces(pieces) = out else { return None };
references_found(id, &plugin, &pieces, doc)?;
references_found(id, &plugin, &pieces, doc, at)?;
Some(match doc {
Some(d) => painted::with_swatch_colors(pieces, d),
None => pieces,
@@ -329,11 +337,17 @@ pub fn apply_painted(
/// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]),
/// recording the outcome for [`last_error`] when there is a document. The document's swatches and
/// patterns can change while a result stays cached, so each use checks them.
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> {
/// patterns can change while a result stays cached, so each use checks them. Inside a document's
/// view, a refusal defers stage `at` as failed.
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>, at: Option<Stage>) -> Option<()> {
let checked = painted::check_references(pieces, doc);
if doc.is_some() {
note(id, plugin, Origin::References, checked.as_ref().map(|_| ()));
if checked.is_err()
&& let Some(v) = crate::deferred::current()
{
v.defer(at.unwrap_or_default(), id, Reason::Failed, 0);
}
}
checked.ok()
}
@@ -341,12 +355,36 @@ fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Opt
/// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what
/// it gave. The launch marker names the plug-in during its first run in this process, and until
/// every run that started while it ran has ended too ([`crate::safe_mode::loading`]).
///
/// Inside a document's view ([`crate::deferred`]), a stage without a result is recorded as
/// deferred with the reason, and one with a result is no longer deferred. During the view's
/// budget a run takes at most what is left of it, none starts once it is spent, and a result
/// that comes back after it ran out is not used; neither is cached or reported by
/// [`last_error`]. After the first view, a stage deferred with the same input is not run again.
fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc<crate::Plugin>, Out)> {
let view = crate::deferred::current();
let stage = paints.at.unwrap_or_default();
if let Some(v) = &view {
v.visit(stage);
}
let defer = |reason, key| {
if let Some(v) = &view {
v.defer(stage, id, reason, key);
}
};
if crate::safe_mode::active() {
defer(Reason::SafeMode, 0);
return None;
}
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
let Some(plugin) = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect) else {
defer(Reason::Missing, 0);
return None;
};
let Ok(resolved) = plugin.manifest().resolve_params(params) else {
defer(Reason::Failed, 0);
return None;
};
let resolved = Value::Object(resolved);
let given = paints.given();
let key = Key {
serial: plugin.serial(),
@@ -357,14 +395,44 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
path: path.clone(),
};
let h = key.hash64();
if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h))
&& *k == key
{
return out.clone().map(|o| (plugin, o));
let cached = CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|c| c.entries.get(&h))
.filter(|(k, _)| *k == key)
.map(|(_, out)| out.clone());
match cached {
// A result from the cache counts as a run that gave it.
Some(Ok(o)) => {
if let Some(v) = &view {
v.ran(stage, h);
}
return Some((plugin, o));
}
// A refresh runs a stage that failed again.
Some(Err(reason)) if !view.as_ref().is_some_and(|v| v.retries(stage, h)) => {
defer(reason, h);
return None;
}
_ => {}
}
let allowance = match &view {
Some(v) if v.holds(stage, h) => return None,
Some(v) => v.allowance(),
None => Allowance::Unlimited,
};
let (wall_time_ms, until) = match allowance {
Allowance::Unlimited => (WALL_TIME_MS, None),
Allowance::Until { ms, until } => (ms.min(WALL_TIME_MS), Some(until)),
Allowance::Spent => {
defer(Reason::Budget, h);
return None;
}
};
let scope = paints.scope();
let guarded = begin_run(&plugin);
let r = run(&plugin, resolved, path, bounds, rule, given, scope);
let r = run(&plugin, resolved, path, (bounds, rule), given, scope, wall_time_ms);
let cancelled = matches!(r, Err(Error::Cancelled));
if guarded {
end_run(plugin.serial(), cancelled);
@@ -373,11 +441,28 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
if cancelled {
return None;
}
let timed_out = matches!(&r, Err(Error::Limit(l)) if l == crate::host::TIME_BUDGET);
// A run the budget cut short, or whose result came after the budget ran out, says nothing
// about the plug-in either: the stage waits for a later run.
let late = until.is_some_and(|u| view.as_ref().is_some_and(|v| v.passed(u)));
if (timed_out && wall_time_ms < WALL_TIME_MS) || late {
defer(Reason::Budget, h);
return None;
}
note(id, &plugin, Origin::Run, r.as_ref().map(|_| ()));
let out = r.ok();
let size = weight(&key, out.as_ref());
let out = r.map_err(|_| if timed_out { Reason::Timeout } else { Reason::Failed });
match &out {
Ok(_) => {
if let Some(v) = &view {
v.ran(stage, h);
}
crate::deferred::resolved(h);
}
Err(reason) => defer(*reason, h),
}
let size = weight(&key, out.as_ref().ok());
if size > CACHE_ANCHORS / 4 {
return out.map(|o| (plugin, o));
return out.ok().map(|o| (plugin, o));
}
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(Cache::default);
@@ -385,15 +470,24 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
*cache = Cache::default();
}
if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) {
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref()));
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref().ok()));
}
cache.anchors += size;
out.map(|o| (plugin, o))
out.ok().map(|o| (plugin, o))
}
/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect
/// that declares `paintOutput` its pieces, checked for an effect in `scope`.
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result<Out> {
/// One run of `plugin` on `path` (reference box and fill rule `(bounds, rule)`), with the object's
/// paints as `given`, stopped after `wall_time_ms`: geometry, or for an effect that declares
/// `paintOutput` its pieces, checked for an effect in `scope`.
fn run(
plugin: &crate::Plugin,
mut params: Value,
path: &PathData,
(bounds, rule): (Rect, FillRule),
given: Given,
scope: Scope,
wall_time_ms: u64,
) -> Result<Out> {
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item});
let rule_name = match rule {
@@ -404,7 +498,9 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect,
json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
let limits = Limits { wall_time_ms, ..plugin.limits().clone() };
#[cfg(test)]
tests::RUNS_STARTED.with(|n| n.set(n.get() + 1));
let output = plugin.run_limited(&input, &params, &limits)?;
if plugin.manifest().paint_output {
return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?)));
@@ -420,6 +516,11 @@ mod tests {
use super::*;
thread_local! {
/// How many plug-in runs this thread started ([`run`]).
pub(super) static RUNS_STARTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Fills and strokes come in paint order, and the item an effect sits on is counted among the
/// fills or among the strokes.
#[test]
@@ -427,7 +528,7 @@ mod tests {
let fill = |c| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
let stroke = |w| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), w));
let ap = Appearance { items: vec![fill(Color::WHITE), stroke(2.0), fill(Color::BLACK), stroke(5.0)], ..Appearance::default() };
let given = |item| Paints { appearance: Some(&ap), item }.given();
let given = |item| Paints { appearance: Some(&ap), item, at: None }.given();
let g = given(None);
assert_eq!((g.fills.len(), g.strokes.len(), &g.item), (2, 2, &json!("object")));
assert_eq!(g.strokes.iter().map(|s| s["width"].clone()).collect::<Vec<_>>(), [json!(2.0), json!(5.0)]);
@@ -494,9 +595,141 @@ mod tests {
assert!(last_error(ID).unwrap().contains("trapped"));
assert!(draw(&short).is_none(), "from the cache, refused again");
assert!(last_error(ID).unwrap().contains("swatch"));
// Inside a document's view, the refused stage is deferred as failed, also from the cache.
let view = crate::deferred::Deferrals::with_clock(Arc::new(|| Some(0)));
let at = Stage { object: 3, effect: 1, item: None };
let in_view = || {
view.view(|| {
apply_painted(
ID,
&json!({}),
&short,
Rect::new(0.0, 0.0, 1.0, 1.0),
FillRule::NonZero,
Paints { at: Some(at), ..Paints::default() },
Some(&doc),
)
})
};
assert!(in_view().is_none());
let listed = || view.list().iter().map(|d| (d.stage, d.reason)).collect::<Vec<_>>();
assert_eq!(listed(), [(at, Reason::Failed)]);
view.first_view_done();
assert!(in_view().is_none());
assert_eq!(listed(), [(at, Reason::Failed)], "a later drawing leaves it listed");
registry::remove(ID);
}
/// A clock that reads `step` milliseconds later each time it is read.
fn stepping(step: u64) -> crate::deferred::Clock {
let t = Arc::new(std::sync::atomic::AtomicU64::new(0));
Arc::new(move || Some(t.fetch_add(step, std::sync::atomic::Ordering::Relaxed)))
}
/// A line of `n` points: a distinct input for each `n`.
fn line(n: usize) -> PathData {
PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false))
}
/// Plug-in `id` drawn on `path` as effect `effect` of object 1: whether it gave a result.
fn drawn(id: &str, path: &PathData, effect: usize) -> bool {
let at = Some(Stage { object: 1, effect, item: None });
apply(id, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints { at, ..Paints::default() }, None).is_some()
}
/// The effects of a first view share its budget: once it is spent no run starts, and a result
/// that comes back after it ran out is not used. Later drawings leave the deferred stages
/// deferred; a drawing outside the view (an export) runs them, and the view then uses the
/// result.
#[test]
fn the_first_view_shares_the_budget_and_defers_the_rest() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.budget";
let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
// Each run takes 900 ms by this clock (it is read before and after each run).
let view = crate::deferred::Deferrals::with_clock(stepping(450));
let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::<Vec<_>>());
assert_eq!(ran, [true, true, false, false], "the third result comes back at 2,250 ms, the fourth doesn't start");
let listed = view.list().iter().map(|d| (d.stage.effect, d.reason, d.plugin.clone())).collect::<Vec<_>>();
assert_eq!(listed, [(2, Reason::Budget, ID.to_string()), (3, Reason::Budget, ID.to_string())]);
view.first_view_done();
assert_eq!(view.take_report(), Some(2));
assert!(!view.view(|| drawn(ID, &line(4), 2)), "a later drawing leaves it deferred");
assert!(view.view(|| drawn(ID, &line(9), 3)), "an edit to its input runs it");
assert_eq!(view.list().len(), 1);
assert!(drawn(ID, &line(4), 2), "outside the view it runs");
assert_eq!(view.list().len(), 1, "until the view draws it again");
assert!(view.view(|| drawn(ID, &line(9), 2)), "an edit to an input whose result is cached");
assert!(view.list().is_empty(), "the record of the input it no longer has goes");
assert!(view.view(|| drawn(ID, &line(4), 2)), "from the cache");
assert!(view.list().is_empty());
registry::remove(ID);
}
/// A stage that fails, or whose plug-in is missing, is deferred with that reason, from a run or
/// from the cache; a failure is cached and reported as before.
#[test]
fn failed_and_missing_stages_are_deferred_with_their_reason() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.deferfailed";
// Runs to its instruction budget.
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let view = crate::deferred::Deferrals::with_clock(stepping(1));
assert!(!view.view(|| drawn(ID, &line(2), 0)));
assert!(last_error(ID).is_some());
assert!(!view.view(|| drawn("test.effect.notinstalled", &line(2), 1)));
let reasons = |v: &crate::deferred::Deferrals| v.list().iter().map(|d| d.reason).collect::<Vec<_>>();
assert_eq!(reasons(&view), [Reason::Failed, Reason::Missing]);
let other = crate::deferred::Deferrals::with_clock(stepping(1));
assert!(!other.view(|| drawn(ID, &line(2), 0)));
assert_eq!(reasons(&other), [Reason::Failed], "from the cache, in another document");
// A refresh runs a stage whose failure is cached again, once, and later drawings take the
// failure from the cache.
other.first_view_done();
let runs = || RUNS_STARTED.with(std::cell::Cell::get);
let before = runs();
let counts = other.refresh(None, |_| {
other.view(|| (drawn(ID, &line(2), 0), drawn(ID, &line(2), 0)));
vec![]
});
assert_eq!((counts.failed, runs() - before), (1, 1));
assert!(!other.view(|| drawn(ID, &line(2), 0)));
assert_eq!((reasons(&other), runs() - before), (vec![Reason::Failed], 1));
registry::remove(ID);
}
/// A run the budget cuts short is deferred for want of time: it is not cached, and the plug-in
/// reports no error.
#[test]
fn a_run_the_budget_cuts_short_is_deferred_for_time() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.cutshort";
const FAST: &str = "test.effect.cutshort.fast";
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#);
let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() };
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap());
let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap());
// The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and
// ten million steps take longer.
let reads = Arc::new(std::sync::atomic::AtomicU64::new(0));
let clock: crate::deferred::Clock =
Arc::new(move || Some(if reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 { 0 } else { 1_999 }));
let view = crate::deferred::Deferrals::with_clock(clock);
assert!(view.view(|| drawn(FAST, &line(2), 0)));
assert!(!view.view(|| drawn(ID, &line(2), 1)));
assert_eq!(view.list().iter().map(|d| (d.stage.effect, d.reason)).collect::<Vec<_>>(), [(1, Reason::Budget)]);
assert_eq!(last_error(ID), None);
assert!(drawn(ID, &line(2), 1), "not cached: outside the view it runs to the end");
registry::remove(ID);
registry::remove(FAST);
}
/// A first run that is cancelled leaves the next run to be the first: the launch marker names
/// the plug-in during that one.
#[test]
+4 -1
View File
@@ -318,6 +318,9 @@ pub struct Report {
pub truncated: bool,
}
/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]).
pub(crate) const TIME_BUDGET: &str = "time budget";
/// When a run must stop (native: a wall-clock deadline).
#[derive(Clone, Copy)]
pub(crate) struct Deadline {
@@ -344,7 +347,7 @@ impl Deadline {
}
pub(crate) fn check(&self) -> Result<()> {
if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) }
if self.passed() { Err(Error::Limit(TIME_BUDGET.into())) } else { Ok(()) }
}
}
+1
View File
@@ -34,6 +34,7 @@
//! runs through `vc_run` with the command's id in `_context`.
#![forbid(unsafe_code)]
pub mod deferred;
pub mod dialog;
pub mod effect;
pub mod export;
+3 -3
View File
@@ -123,9 +123,9 @@ pub struct CacheKey {
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
pub placed: u64,
/// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in
/// install or removal after which the document drew a plug-in effect
/// ([`vectorcraft_render::draws_plugin_effects`]): a frame drawn before it draws those effects
/// as the plug-ins installed then did. 0 until then.
/// install or removal, or of the last time deferred plug-in effect stages gave results, after
/// which the document drew a plug-in effect ([`vectorcraft_render::draws_plugin_effects`]): a
/// frame drawn before it draws those effects as the plug-ins installed then did. 0 until then.
pub plugins: u64,
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
/// with hard edges. None: the art is rendered for the screen.
+29 -5
View File
@@ -293,6 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let effects = st.plugin_effects.clone();
let mask_view = st.shown_mask();
paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view()));
@@ -349,8 +350,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
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 });
draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel });
}
report_deferred(app, &effects);
// The status bar reports each image that a renderer (this thread's or the worker's) could not
// decode, once per image in the process.
let undecoded = vectorcraft_render::newly_undecodable(doc.images.values());
@@ -606,7 +608,7 @@ fn cache_key(
/// [`CacheKey::plugins`] of document `st`, one of the documents open in `session`. `keys` holds each
/// document's value and the plug-in revision it was checked at, so a document is looked through only
/// once after each plug-in install or removal.
/// once after each change of the plug-in revision.
fn plugin_key(
keys: &mut std::collections::HashMap<u64, (u64, u64)>,
session: &vectorcraft_engine::Session,
@@ -624,6 +626,19 @@ fn plugin_key(
}
}
/// Once the first view of a document has been drawn, the status bar says how many plug-in effect
/// stages it left deferred, if any.
fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) {
if let Some(n) = effects.take_report().filter(|n| *n > 0) {
app.status(crate::i18n::msg_n(
n as u64,
"Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.",
"Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them.",
&[],
));
}
}
/// Start the render worker, and upload the frame it finished.
fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) {
if !app.canvas.worker_started {
@@ -647,6 +662,9 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate:
return;
}
upload(app, ctx, done.img, done.key.pixel.is_some());
if let Some(d) = app.session.documents().iter().find(|d| d.uid as usize == done.key.doc) {
d.plugin_effects.first_view_done();
}
app.canvas.key = Some(done.key);
app.canvas.shown = Some((done.doc, done.opts));
app.perf.render_ms = done.ms;
@@ -729,6 +747,8 @@ fn frame_options(
struct Frame<'a> {
key: &'a CacheKey,
doc: &'a std::sync::Arc<vectorcraft_doc::Document>,
/// The document's deferred plug-in effect stages: the frame renders inside their view.
effects: &'a vectorcraft_plugins::deferred::Deferrals,
size: (u32, u32),
view: Affine,
opts: vectorcraft_render::RenderOptions,
@@ -740,7 +760,7 @@ struct Frame<'a> {
pub(crate) const FIRST_FRAME_GRACE: std::time::Duration = std::time::Duration::from_millis(16);
fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
let Frame { key, doc, size: (w, h), view, opts, pixel } = frame;
let Frame { key, doc, effects, size: (w, h), view, opts, pixel } = frame;
// Frames that would be slow on this thread (the last one took over 8 ms) go
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
@@ -768,7 +788,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
app.canvas.renderer.let_go_of_measured();
}
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
let renderer = &mut app.canvas.renderer;
let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts));
let color = color_image(&part);
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
tex.set_partial([x0 as usize, y0 as usize], color, options);
@@ -790,6 +811,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
view,
opts,
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
effects: effects.clone(),
may_copy,
may_keep: true,
});
@@ -801,7 +823,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
}
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(doc, w, h, view, &opts);
let renderer = &mut app.canvas.renderer;
let img = effects.view(|| renderer.render(doc, w, h, view, &opts));
effects.first_view_done();
upload(app, ui.ctx(), img, pixel.is_some());
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
+2
View File
@@ -95,6 +95,7 @@ pub(super) fn fill_uncovered(
let (_, _, view) = fill_view(&done.key);
app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex });
}
let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default();
let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return };
if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) {
let (w, h, view) = fill_view(key);
@@ -106,6 +107,7 @@ pub(super) fn fill_uncovered(
view,
opts,
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
effects,
may_copy: false,
may_keep: false,
});
+39 -1
View File
@@ -178,7 +178,11 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the
/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the
/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a
/// double-click.
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
let [top, left, corner] = ruler_rects(full);
// Right-click on a ruler or the origin box: the document units, to swap between them as
@@ -189,6 +193,40 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
// (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it.
let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG);
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
// egui counts a double-click by position and time alone, so a click on the canvas right
// at the ruler and one on the ruler would pass for one: the pair counts only when the
// ruler's own click before it came within the double-click delay.
let last_click = egui::Id::unique(if vertical { "ruler-left-last-click" } else { "ruler-top-last-click" });
let now = ui.input(|i| i.time);
let first_here =
ui.data(|d| d.get_temp::<f64>(last_click)).is_some_and(|t| now - t <= ui.ctx().options(|o| o.input_options.max_double_click_delay));
if resp.clicked_by(egui::PointerButton::Primary) {
ui.data_mut(|d| d.insert_temp(last_click, now));
}
if resp.double_clicked_by(egui::PointerButton::Primary)
&& first_here
&& let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))
{
// The same document point the drag path reads. A ruler reads the canvas's coordinates
// turned with the view, so the guide is the one whose line crosses the ruler's axis:
// the vertical one when that axis (the screen's x for the top ruler, y for the left,
// in document directions) is nearer the document's x, else the horizontal one. At 0°
// that is a vertical guide from the top ruler; at 90° a horizontal one; between, the
// nearer axis, whose line still runs through the point and never along the ruler.
let at = xf.to_doc(p);
let (sn, cs) = xf.rot.sin_cos();
let axis = if vertical { (sn, cs) } else { (cs, -sn) };
let across = axis.0.abs() >= axis.1.abs();
let mut params = json!({"vertical": across, "pos": if across { at.x } else { at.y }});
if let Some(ab) = app.session.ruler_guide_artboard() {
params["artboard"] = json!(ab);
}
// The new guide shows even if guides were hidden.
app.ui.view.guides = true;
if let Err(e) = app.run("guide.add", params) {
app.status(e.to_string());
}
}
let kind = if resp.drag_stopped() {
PointerKind::Up
} else if resp.dragged() {
+192
View File
@@ -2395,6 +2395,52 @@ fn undecodable_images_are_marked_and_reported_once() {
assert_eq!(app.ui.status, "", "said once");
}
/// The plug-in effects a document's first view leaves out past its budget are reported in the
/// status bar once, in the singular or the plural, and Effect › Refresh Deferred Effects is
/// enabled until it has run them.
#[test]
fn the_first_view_reports_the_plug_in_effects_it_deferred() {
use std::sync::atomic::{AtomicU64, Ordering};
use vectorcraft_plugins::wat as tpl;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.canvas.worker_started = true; // render on this thread
let module = tpl::module(&tpl::manifest("test.ui.deferred", "effect", "{}"), tpl::ECHO, "", 1);
app.session.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(module).unwrap())})).unwrap();
// Each reading of the clock is 3 s later: the first run's result comes back after the budget,
// and no later run starts.
let step = std::sync::Arc::new(AtomicU64::new(3_000));
let ctx = egui::Context::default();
for (count, said) in [
(2, "Opened with 2 plug-in effects deferred. Effect › Refresh Deferred Effects runs them."),
(1, "Opened with 1 plug-in effect deferred. Effect › Refresh Deferred Effects runs it."),
] {
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
// Art the plug-in has not been given before: a result it gave is kept and drawn at once.
for i in 0..count {
let x = 31.0 + 97.0 * f64::from(i) + 13.0 * f64::from(count);
let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 47, "width": 23, "height": 29})).unwrap()["id"].clone();
app.session.execute("effect.apply", &json!({"ids": [id], "effect": "plugin.test.ui.deferred"})).unwrap();
}
app.session.execute("select.none", &json!({})).unwrap();
let (now, by) = (std::sync::Arc::new(AtomicU64::new(0)), step.clone());
let clock: vectorcraft_plugins::deferred::Clock =
std::sync::Arc::new(move || Some(now.fetch_add(by.load(Ordering::Relaxed), Ordering::Relaxed)));
app.session.doc_mut().unwrap().plugin_effects = vectorcraft_plugins::deferred::Deferrals::with_clock(clock);
step.store(3_000, Ordering::Relaxed);
app.status("");
frame(&mut app, &ctx, vec![]);
assert_eq!(app.ui.status, said);
assert!(crate::menus::enabled(&app, "effect.refreshDeferred"));
app.status("");
app.session.doc_mut().unwrap().revision += 1;
frame(&mut app, &ctx, vec![]);
assert_eq!(app.ui.status, "", "said once");
step.store(0, Ordering::Relaxed);
app.session.execute("effect.refreshDeferred", &json!({})).unwrap();
assert!(!crate::menus::enabled(&app, "effect.refreshDeferred"), "nothing is left deferred");
}
}
/// A drag of an object with raster effects (#1257): the drag's frames render while
/// interacting and draw the object's drop shadow and filtered content from the rasters of the
/// frame before; the frame after the drag renders without `interacting`, on the render worker,
@@ -10701,6 +10747,7 @@ fn pan_job(doc: &std::sync::Arc<vectorcraft_doc::Document>, key: &CacheKey, (dx,
view: Affine::translate((dx, dy)),
opts: vectorcraft_render::RenderOptions::default(),
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
effects: Default::default(),
may_copy: true,
may_keep: true,
}
@@ -11318,6 +11365,151 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() {
assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged");
}
/// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler
/// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when
/// guides were hidden. The first click of the pair adds nothing.
#[test]
fn double_clicking_a_ruler_adds_a_guide_at_that_position() {
// The ruler to click, the guide's orientation and its position, the pointer at that position.
for (top, vertical, pos) in [(true, true, 120.0), (false, false, 80.0)] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.view.rulers = true;
app.ui.view.guides = false;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let view = app.view_mut().unwrap();
view.zoom = 2.0;
view.rotation = 0.0;
view.center = Point::new(200.0, 150.0);
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let at = if top {
pos2(xf.to_screen(Point::new(pos, 0.0)).x, rect.top() - RULER / 2.0)
} else {
pos2(rect.left() - RULER / 2.0, xf.to_screen(Point::new(0.0, pos)).y)
};
let guides = |app: &mut VectorcraftApp| {
let list = app.session.execute("guide.list", &json!({})).unwrap();
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect::<Vec<_>>()
};
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let before = steps(&app);
let click = |app: &mut VectorcraftApp| {
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
frame(app, &ctx, vec![button(true)]);
frame(app, &ctx, vec![button(false)]);
};
click(&mut app);
assert_eq!(guides(&mut app), vec![], "a single click adds none");
click(&mut app);
let got = guides(&mut app);
assert_eq!(got.len(), 1, "top ruler: {top}, {got:?}");
assert!(got[0].0 == vertical && (got[0].1 - pos).abs() < 0.01, "top ruler: {top}, want vertical {vertical} at {pos}: {got:?}");
assert!(app.ui.view.guides, "the new guide is shown");
assert_eq!(steps(&app), before + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(guides(&mut app), vec![]);
}
}
/// A fresh app with a 400 x 300 document, rulers on, the view at zoom 2 turned `rotation` degrees,
/// and its first frame drawn.
fn ruler_guide_app(rotation: f64) -> (VectorcraftApp, egui::Context) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.view.rulers = true;
app.ui.view.guides = false;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let view = app.view_mut().unwrap();
view.zoom = 2.0;
view.rotation = rotation;
view.center = Point::new(200.0, 150.0);
frame(&mut app, &ctx, vec![]);
(app, ctx)
}
/// One primary click at `at`, as three frames (move, press, release).
fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![egui::Event::PointerMoved(at)]);
frame(app, ctx, vec![button(true)]);
frame(app, ctx, vec![button(false)]);
}
/// The ruler guides as (vertical, position).
fn guide_list(app: &mut VectorcraftApp) -> Vec<(bool, f64)> {
let list = app.session.execute("guide.list", &json!({})).unwrap();
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect()
}
/// #5239 review: egui counts a double-click by position and time, not by widget, so a click on
/// the canvas right at a ruler followed at once by a click on the ruler is not a double-click on
/// the ruler. No guide; two clicks on the ruler still make one.
#[test]
fn a_canvas_click_then_a_ruler_click_adds_no_guide() {
for (top, canvas_first) in [(true, true), (false, true), (true, false), (false, false)] {
let (mut app, ctx) = ruler_guide_app(0.0);
let rect = app.canvas_rect.unwrap();
// Two points less than 6 pt apart, either side of the ruler's inner edge.
let (on_canvas, on_ruler) = if top {
(pos2(rect.center().x, rect.top() + 1.0), pos2(rect.center().x, rect.top() - 1.0))
} else {
(pos2(rect.left() + 1.0, rect.center().y), pos2(rect.left() - 1.0, rect.center().y))
};
click_at(&mut app, &ctx, if canvas_first { on_canvas } else { on_ruler });
click_at(&mut app, &ctx, on_ruler);
let want = usize::from(!canvas_first);
assert_eq!(guide_list(&mut app).len(), want, "top ruler: {top}, first click on the canvas: {canvas_first}");
}
}
/// #5239 review: the rulers read the canvas's coordinates turned with the view, so a guide made
/// by a double-click runs across the ruler's axis in any view: it passes through the clicked
/// point and crosses the ruler's edge at it, never along it. At 90° the top ruler reads the
/// document's y, so its guide is horizontal; the left ruler reads x, so its guide is vertical.
/// A view turned part of the way takes the document axis nearer to the ruler's.
#[test]
fn a_ruler_double_click_follows_a_rotated_view() {
// Rotation (degrees), top ruler's guide is vertical, left ruler's guide is vertical.
for (rotation, top_vertical, left_vertical) in [
(0.0, true, false),
(90.0, false, true),
(-90.0, false, true),
(180.0, true, false),
(270.0, false, true),
(30.0, true, false),
(60.0, false, true),
] {
for top in [true, false] {
let (mut app, ctx) = ruler_guide_app(rotation);
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let at =
if top { pos2(rect.center().x + 37.0, rect.top() - RULER / 2.0) } else { pos2(rect.left() - RULER / 2.0, rect.center().y + 23.0) };
click_at(&mut app, &ctx, at);
click_at(&mut app, &ctx, at);
let got = guide_list(&mut app);
let label = format!("rotation {rotation}, top ruler: {top}: {got:?}");
assert_eq!(got.len(), 1, "{label}");
let (vertical, pos) = got[0];
assert_eq!(vertical, if top { top_vertical } else { left_vertical }, "{label}");
// Through the clicked point.
let doc = xf.to_doc(at);
assert!((pos - if vertical { doc.x } else { doc.y }).abs() < 0.01, "{label}: through {doc:?}");
// Its direction on screen is not along the ruler: the line crosses the ruler's edge.
let along = if vertical { Point::new(doc.x, doc.y + 1.0) } else { Point::new(doc.x + 1.0, doc.y) };
let dir = (xf.to_screen(along) - xf.to_screen(doc)).normalized();
let across = if top { dir.y.abs() } else { dir.x.abs() };
assert!(across > 0.5, "{label}: the guide runs along the ruler ({dir:?})");
}
}
}
/// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer
/// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with
/// the 3D art it made to measure it.
+11
View File
@@ -3071,6 +3071,13 @@ digits Malayalam Малаялам
Quest Outline Квест
Coin Shine Монета
Boss Alert Бос
Status Effects Статус ефекти
Poison Tint Отрова
Freeze Edge Замразяване
Burn Aura Изгаряне
Stun Pulse Зашеметяване
Stealth Fade Скритост
Shield Buff Щит
Shape Фигура
Shape Builder Създател на фигури
Shape Builder Regions Области на създателя на фигури
@@ -6522,3 +6529,7 @@ breakText Glyphs Глифи
@plural {n} host call|{n} host calls {n} извикване към хоста|{n} извиквания към хоста
@plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати
@plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени
# Deferred plug-in effects
Refresh Deferred Effects Обновяване на отложените ефекти
Deferred Effects Отложени ефекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -776,6 +776,13 @@
Quest Outline Zadatak
Coin Shine Novčić
Boss Alert Bos
Status Effects Statusni efekti
Poison Tint Otrov
Freeze Edge Zamrzavanje
Burn Aura Izgaranje
Stun Pulse Omamljenost
Stealth Fade Nevidljivost
Shield Buff Štit
Outlines and Rules Obrisi i linije
Hand-Drawn Ručno crtano
Gradient Finishes Gradijentne završnice
@@ -6573,3 +6580,7 @@ shape Open Otvoren
@plural {n} host call|{n} host calls {n} poziv hosta|{n} poziva hosta|{n} poziva hosta
@plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno
@plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno
# Deferred plug-in effects
Refresh Deferred Effects Osvježi odgođene efekte
Deferred Effects Odgođeni efekti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -3955,6 +3955,13 @@ digits Malayalam Malaiàlam
Quest Outline Missió
Coin Shine Moneda
Boss Alert Cap
Status Effects Efectes d’estat
Poison Tint Verí
Freeze Edge Congelació
Burn Aura Cremada
Stun Pulse Atordiment
Stealth Fade Sigil
Shield Buff Escut
Shape Forma
Shape Builder Constructor de formes
Shape Builder Delete Suprimeix del constructor de formes
@@ -6778,3 +6785,9 @@ breakText Glyphs Glifs
@plural {n} earlier line dropped|{n} earlier lines dropped {n} línia anterior descartada|{n} línies anteriors descartades
@plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last`
# Deferred plug-in effects
Refresh Deferred Effects Actualitza els efectes ajornats
Deferred Effects Efectes ajornats
@msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -783,6 +783,13 @@
Quest Outline Úkol
Coin Shine Mince
Boss Alert Boss
Status Effects Stavové efekty
Poison Tint Jed
Freeze Edge Zmrazení
Burn Aura Spálení
Stun Pulse Omráčení
Stealth Fade Plížení
Shield Buff Štít
Outlines and Rules Obrysy a linky
Hand-Drawn Ručně kreslené
Gradient Finishes Přechodové povrchy
@@ -6797,3 +6804,9 @@ breakText Glyphs Glyfy
@plural {n} earlier line dropped|{n} earlier lines dropped {n} starší řádek zahozen|{n} starší řádky zahozeny|{n} starších řádků zahozeno
@plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last`
# Deferred plug-in effects
Refresh Deferred Effects Obnovit odložené efekty
Deferred Effects Odložené efekty
@msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -722,6 +722,13 @@
Quest Outline Quest
Coin Shine Mønt
Boss Alert Boss
Status Effects Statuseffekter
Poison Tint Gift
Freeze Edge Frost
Burn Aura Brænd
Stun Pulse Bedøvelse
Stealth Fade Snige
Shield Buff Skjold
Outlines and Rules Konturer og linjer
Hand-Drawn Håndtegnet
Gradient Finishes Gradientfinish
@@ -6536,3 +6543,7 @@ breakText Glyphs Glyffer
@plural {n} host call|{n} host calls {n} værtskald|{n} værtskald
@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet
@plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet
# Deferred plug-in effects
Refresh Deferred Effects Opdatér udskudte effekter
Deferred Effects Udskudte effekter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -3117,6 +3117,13 @@ digits Malayalam Malayalam
Quest Outline Quest
Coin Shine Münze
Boss Alert Boss
Status Effects Statuseffekte
Poison Tint Gift
Freeze Edge Frost
Burn Aura Brand
Stun Pulse Betäubung
Stealth Fade Schleichen
Shield Buff Schild
Shape Form
Shape Builder Formerstellung
Shape Builder Regions Formerstellungsbereiche
@@ -6384,3 +6391,9 @@ breakText Glyphs Glyphen
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ältere Zeile verworfen|{n} ältere Zeilen verworfen
@plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last`
# Deferred plug-in effects
Refresh Deferred Effects Zurückgestellte Effekte aktualisieren
Deferred Effects Zurückgestellte Effekte
@msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -3625,6 +3625,13 @@ digits Malayalam Μαλαγιάλαμ
Quest Outline Αποστολή
Coin Shine Νόμισμα
Boss Alert Αρχηγός
Status Effects Εφέ κατάστασης
Poison Tint Δηλητήριο
Freeze Edge Πάγωμα
Burn Aura Κάψιμο
Stun Pulse Ζάλη
Stealth Fade Αορασία
Shield Buff Ασπίδα
Shape Builder Regions Περιοχές δημιουργίας σχημάτων
Shape Effects Εφέ σχημάτων
Shape Mode Λειτουργία σχήματος
@@ -6749,3 +6756,9 @@ breakText Glyphs Γλύφοι
@plural {n} earlier line dropped|{n} earlier lines dropped {n} παλαιότερη γραμμή απορρίφθηκε|{n} παλαιότερες γραμμές απορρίφθηκαν
@plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν
@msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last`
# Deferred plug-in effects
Refresh Deferred Effects Ανανέωση εφέ σε αναμονή
Deferred Effects Εφέ σε αναμονή
@msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί.
Can't render this file because it is too large.
+13
View File
@@ -3156,6 +3156,13 @@ digits Malayalam Malayálam
Quest Outline Misión
Coin Shine Moneda
Boss Alert Jefe
Status Effects Efectos de estado
Poison Tint Veneno
Freeze Edge Congelación
Burn Aura Quemadura
Stun Pulse Aturdimiento
Stealth Fade Sigilo
Shield Buff Escudo
Shape Forma
Shape Builder Generador de formas
Shape Builder Regions Regiones del generador de formas
@@ -6740,3 +6747,9 @@ breakText Glyphs Glifos
@plural {n} earlier line dropped|{n} earlier lines dropped {n} línea anterior descartada|{n} líneas anteriores descartadas
@plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last`
# Deferred plug-in effects
Refresh Deferred Effects Actualizar efectos aplazados
Deferred Effects Efectos aplazados
@msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -774,6 +774,13 @@
Quest Outline Tehtävä
Coin Shine Kolikko
Boss Alert Bossi
Status Effects Tilatehosteet
Poison Tint Myrkky
Freeze Edge Jäädytys
Burn Aura Poltto
Stun Pulse Tainnutus
Stealth Fade Hiipiminen
Shield Buff Kilpi
Outlines and Rules Ääriviivat ja viivat
Hand-Drawn Käsin piirretyt
Gradient Finishes Liukuväriviimeistelyt
@@ -6832,3 +6839,9 @@ breakText Glyphs Merkit
@plural {n} earlier line dropped|{n} earlier lines dropped {n} aiempi rivi poistettu|{n} aiempaa riviä poistettu
@plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last`
# Deferred plug-in effects
Refresh Deferred Effects Päivitä lykätyt tehosteet
Deferred Effects Lykätyt tehosteet
@msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -3148,6 +3148,13 @@ digits Malayalam Malayalam
Quest Outline Quête
Coin Shine Pièce
Boss Alert Boss
Status Effects Effets de statut
Poison Tint Poison
Freeze Edge Gel
Burn Aura Brûlure
Stun Pulse Étourdissement
Stealth Fade Furtivité
Shield Buff Bouclier
Shape Forme
Shape Builder Concepteur de forme
Shape Builder Regions Zones du Concepteur de forme
@@ -6792,3 +6799,9 @@ breakText Glyphs Glyphes
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ligne antérieure supprimée|{n} lignes antérieures supprimées
@plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last`
# Deferred plug-in effects
Refresh Deferred Effects Actualiser les effets différés
Deferred Effects Effets différés
@msg no plug-in effect is deferred aucun effet de plug-in n’est différé
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4267,6 +4267,13 @@ antiAlias Sharp शार्प
Quest Outline क्वेस्ट
Coin Shine सिक्का
Boss Alert बॉस
Status Effects स्थिति प्रभाव
Poison Tint ज़हर
Freeze Edge ठंड
Burn Aura जलन
Stun Pulse स्तब्ध
Stealth Fade छिपना
Shield Buff ढाल
Shaft: दस्ता:
Shape शेप
Shape Builder शेप बिल्डर
@@ -8238,3 +8245,9 @@ theme Appearance अपीयरेंस
@plural {n} earlier line dropped|{n} earlier lines dropped {n} पुरानी पंक्ति हटाई गई|{n} पुरानी पंक्तियाँ हटाई गईं
@plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में
# Deferred plug-in effects
Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें
Deferred Effects स्थगित इफ़ेक्ट्स
@msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है।
Can't render this file because it is too large.
+13
View File
@@ -3771,6 +3771,13 @@ mark-style Japanese Japán
Quest Outline Küldetés
Coin Shine Érme
Boss Alert Főellenség
Status Effects Állapot hatások
Poison Tint Méreg
Freeze Edge Fagy
Burn Aura Égés
Stun Pulse Kábítás
Stealth Fade Lopakodás
Shield Buff Pajzs
Shape Alakzat
Shape Builder Alakzatépítő
Shape Builder Regions Alakzatépítő területei
@@ -6781,3 +6788,9 @@ breakText Glyphs Glifák
@plural {n} earlier line dropped|{n} earlier lines dropped {n} korábbi sor elvetve
@plural {n} line cut|{n} lines cut {n} sor levágva
@msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt
# Deferred plug-in effects
Refresh Deferred Effects Elhalasztott effektusok frissítése
Deferred Effects Elhalasztott effektusok
@msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -722,6 +722,13 @@
Quest Outline Quest
Coin Shine Koin
Boss Alert Boss
Status Effects Efek Status
Poison Tint Racun
Freeze Edge Beku
Burn Aura Bakar
Stun Pulse Pingsan
Stealth Fade Menghilang
Shield Buff Perisai
Outlines and Rules Garis luar dan garis
Hand-Drawn Gambar tangan
Gradient Finishes Finishing gradien
@@ -6536,3 +6543,7 @@ breakText Glyphs Glif
@plural {n} host call|{n} host calls {n} panggilan host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang
@plural {n} line cut|{n} lines cut {n} baris dipotong
# Deferred plug-in effects
Refresh Deferred Effects Segarkan efek yang ditunda
Deferred Effects Efek yang ditunda
Can't render this file because it contains an unexpected character in line 2327 and column 5.
+13
View File
@@ -3193,6 +3193,13 @@ digits Malayalam Malayalam
Quest Outline Missione
Coin Shine Moneta
Boss Alert Boss
Status Effects Effetti di stato
Poison Tint Veleno
Freeze Edge Gelo
Burn Aura Bruciatura
Stun Pulse Stordimento
Stealth Fade Furtività
Shield Buff Scudo
Shape Forma
Shape Builder Generatore forme
Shape Builder Regions Aree del Generatore forme
@@ -6418,3 +6425,9 @@ breakText Glyphs Glifi
@plural {n} earlier line dropped|{n} earlier lines dropped {n} riga precedente scartata|{n} righe precedenti scartate
@plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last`
# Deferred plug-in effects
Refresh Deferred Effects Aggiorna effetti rimandati
Deferred Effects Effetti rimandati
@msg no plug-in effect is deferred nessun effetto plug-in è rimandato
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -807,6 +807,13 @@ digits Malayalam マラヤーラム数字
Quest Outline クエスト
Coin Shine コイン
Boss Alert ボス
Status Effects ステータス効果
Poison Tint 毒
Freeze Edge 凍結
Burn Aura 火傷
Stun Pulse 気絶
Stealth Fade ステルス
Shield Buff シールド
Outlines and Rules アウトラインと罫線
Hand-Drawn 手描き風
Gradient Finishes グラデーション仕上げ
@@ -6483,3 +6490,9 @@ breakText Glyphs 字形
@plural {n} earlier line dropped|{n} earlier lines dropped 古い行 {n} 行を破棄
@plural {n} line cut|{n} lines cut {n} 行を切り詰め
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください
# Deferred plug-in effects
Refresh Deferred Effects 保留中の効果を更新
Deferred Effects 保留中の効果
@msg no plug-in effect is deferred 保留中のプラグイン効果はありません
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -670,6 +670,13 @@
Quest Outline ქვესტი
Coin Shine მონეტა
Boss Alert ბოსი
Status Effects სტატუსის ეფექტები
Poison Tint შხამი
Freeze Edge გაყინვა
Burn Aura დაწვა
Stun Pulse დაბნეულობა
Stealth Fade შენიღბვა
Shield Buff ფარი
Outlines and Rules კონტურები და ხაზები
Hand-Drawn ხელით დახატული
Gradient Finishes გრადიენტული ზედაპირები
@@ -6858,3 +6865,9 @@ font-script Japanese იაპონური
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ძველი სტრიქონი წაიშალა
@plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ
# Deferred plug-in effects
Refresh Deferred Effects გადადებული ეფექტების განახლება
Deferred Effects გადადებული ეფექტები
@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს.
Can't render this file because it is too large.
+13
View File
@@ -800,6 +800,13 @@ digits Malayalam 말라얄람어
Quest Outline 퀘스트
Coin Shine 코인
Boss Alert 보스
Status Effects 상태 효과
Poison Tint 독
Freeze Edge 빙결
Burn Aura 화상
Stun Pulse 기절
Stealth Fade 은신
Shield Buff 방패
Outlines and Rules 윤곽선과 괘선
Hand-Drawn 손그림
Gradient Finishes 그레이디언트 마감
@@ -6379,3 +6386,9 @@ breakText Glyphs 글리프
@plural {n} earlier line dropped|{n} earlier lines dropped 이전 줄 {n}개 삭제됨
@plural {n} line cut|{n} lines cut 줄 {n}개 잘림
@msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다
# Deferred plug-in effects
Refresh Deferred Effects 보류된 효과 새로 고침
Deferred Effects 보류된 효과
@msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -736,6 +736,13 @@
Quest Outline Quest
Coin Shine Munt
Boss Alert Baas
Status Effects Statuseffecten
Poison Tint Gif
Freeze Edge Bevriezing
Burn Aura Brand
Stun Pulse Verdoving
Stealth Fade Sluipen
Shield Buff Schild
Outlines and Rules Omtrekken en lijnen
Hand-Drawn Handgetekend
Gradient Finishes Verloopafwerkingen
@@ -6587,3 +6594,7 @@ breakText Glyphs Glyphs
@plural {n} host call|{n} host calls {n} host-aanroep|{n} host-aanroepen
@plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd
@plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort
# Deferred plug-in effects
Refresh Deferred Effects Uitgestelde effecten vernieuwen
Deferred Effects Uitgestelde effecten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -730,6 +730,13 @@
Quest Outline Zadanie
Coin Shine Moneta
Boss Alert Boss
Status Effects Efekty statusu
Poison Tint Trucizna
Freeze Edge Mróz
Burn Aura Oparzenie
Stun Pulse Ogłuszenie
Stealth Fade Ukrycie
Shield Buff Tarcza
Outlines and Rules Kontury i linie
Hand-Drawn Rysowane odręcznie
Gradient Finishes Wykończenia gradientowe
@@ -6653,3 +6660,9 @@ breakText Glyphs Glify
@plural {n} earlier line dropped|{n} earlier lines dropped {n} odrzucony wcześniejszy wiersz|{n} odrzucone wcześniejsze wiersze|{n} odrzuconych wcześniejszych wierszy
@plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last`
# Deferred plug-in effects
Refresh Deferred Effects Odśwież odroczone efekty
Deferred Effects Odroczone efekty
@msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -2648,6 +2648,13 @@ digits Malayalam Malaiala
Quest Outline Missão
Coin Shine Moeda
Boss Alert Chefe
Status Effects Efeitos de status
Poison Tint Veneno
Freeze Edge Congelamento
Burn Aura Queimadura
Stun Pulse Atordoamento
Stealth Fade Furtividade
Shield Buff Escudo
Shape Forma
Shape Builder Construtor de Formas
Shape Builder Regions Regiões do Construtor de Formas
@@ -6870,3 +6877,9 @@ swatch Yellow Green Verde-amarelado
@plural {n} earlier line dropped|{n} earlier lines dropped {n} linha anterior descartada|{n} linhas anteriores descartadas
@plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last`
# Deferred plug-in effects
Refresh Deferred Effects Atualizar Efeitos Adiados
Deferred Effects Efeitos Adiados
@msg no plug-in effect is deferred nenhum efeito de plug-in está adiado
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -3188,6 +3188,13 @@ digits Malayalam Малаялам
Quest Outline Квест
Coin Shine Монета
Boss Alert Босс
Status Effects Эффекты статуса
Poison Tint Яд
Freeze Edge Заморозка
Burn Aura Ожог
Stun Pulse Оглушение
Stealth Fade Скрытность
Shield Buff Щит
Shape Фигура
Shape Builder Создание фигур
Shape Builder Regions Области создания фигур
@@ -6886,3 +6893,9 @@ shape Open Открытый
@plural {n} earlier line dropped|{n} earlier lines dropped {n} прежняя строка отброшена|{n} прежние строки отброшены|{n} прежних строк отброшено
@plural {n} line cut|{n} lines cut {n} строка обрезана|{n} строки обрезаны|{n} строк обрезано
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last`
# Deferred plug-in effects
Refresh Deferred Effects Обновить отложенные эффекты
Deferred Effects Отложенные эффекты
@msg no plug-in effect is deferred нет отложенных эффектов модулей
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.
Can't render this file because it is too large.
+13
View File
@@ -3080,6 +3080,13 @@ digits Malayalam Malayalam
Quest Outline Uppdrag
Coin Shine Mynt
Boss Alert Boss
Status Effects Statuseffekter
Poison Tint Gift
Freeze Edge Frost
Burn Aura Bränna
Stun Pulse Bedövning
Stealth Fade Smyga
Shield Buff Sköld
Shape Form
Shape Builder Formbyggare
Shape Builder Regions Formbyggarområden
@@ -8282,3 +8289,9 @@ digits Hindi Hindi
@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidigare rad borttagen|{n} tidigare rader borttagna
@plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last`
# Deferred plug-in effects
Refresh Deferred Effects Uppdatera uppskjutna effekter
Deferred Effects Uppskjutna effekter
@msg no plug-in effect is deferred ingen plugineffekt är uppskjuten
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+13
View File
@@ -2984,6 +2984,13 @@ digits Malayalam Malayalam
Quest Outline Görev
Coin Shine Jeton
Boss Alert Boss
Status Effects Durum Etkileri
Poison Tint Zehir
Freeze Edge Donma
Burn Aura Yanma
Stun Pulse Sersemletme
Stealth Fade Gizlilik
Shield Buff Kalkan
Shape Şekil
Shape Builder Şekil Oluşturucu
Shape Builder Regions Şekil Oluşturucu Bölgeleri
@@ -6753,3 +6760,9 @@ breakText Glyphs Glifler
@plural {n} earlier line dropped|{n} earlier lines dropped {n} eski satır atıldı
@plural {n} line cut|{n} lines cut {n} satır kesildi
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli
# Deferred plug-in effects
Refresh Deferred Effects Ertelenen Efektleri Yenile
Deferred Effects Ertelenen Efektler
@msg no plug-in effect is deferred ertelenen eklenti efekti yok
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+13
View File
@@ -3185,6 +3185,13 @@ digits Malayalam Малаялам
Quest Outline Квест
Coin Shine Монета
Boss Alert Бос
Status Effects Ефекти статусу
Poison Tint Отрута
Freeze Edge Заморозка
Burn Aura Опік
Stun Pulse Оглушення
Stealth Fade Невидимість
Shield Buff Щит
Shape Фігура
Shape Builder Створення фігур
Shape Builder Regions Області створення фігур
@@ -6398,3 +6405,9 @@ breakText Glyphs Гліфи
@plural {n} earlier line dropped|{n} earlier lines dropped {n} попередній рядок відкинуто|{n} попередні рядки відкинуто|{n} попередніх рядків відкинуто
@plural {n} line cut|{n} lines cut {n} рядок обрізано|{n} рядки обрізано|{n} рядків обрізано
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last`
# Deferred plug-in effects
Refresh Deferred Effects Оновити відкладені ефекти
Deferred Effects Відкладені ефекти
@msg no plug-in effect is deferred немає відкладених ефектів модулів
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.
Can't render this file because it contains an unexpected character in line 182 and column 5.
+13
View File
@@ -3517,6 +3517,13 @@ mark-style Japanese Kiểu Nhật
Quest Outline Nhiệm vụ
Coin Shine Xu
Boss Alert Trùm
Status Effects Hiệu ứng trạng thái
Poison Tint Độc
Freeze Edge Đóng băng
Burn Aura Bỏng
Stun Pulse Choáng
Stealth Fade Ẩn thân
Shield Buff Khiên
Shape Hình dạng
Shape Builder Tạo hình dạng
Shape Builder Regions Vùng Tạo hình dạng
@@ -6399,3 +6406,9 @@ breakText Glyphs Glyph
@plural {n} earlier line dropped|{n} earlier lines dropped đã bỏ {n} dòng cũ
@plural {n} line cut|{n} lines cut đã cắt {n} dòng
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last`
# Deferred plug-in effects
Refresh Deferred Effects Làm mới hiệu ứng bị hoãn
Deferred Effects Hiệu ứng bị hoãn
@msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng.
Can't render this file because it contains an unexpected character in line 204 and column 5.
+13
View File
@@ -3023,6 +3023,13 @@ digits Malayalam 马拉雅拉姆数字
Quest Outline 任务
Coin Shine 金币
Boss Alert 首领
Status Effects 状态效果
Poison Tint 中毒
Freeze Edge 冰冻
Burn Aura 灼烧
Stun Pulse 眩晕
Stealth Fade 隐身
Shield Buff 护盾
Shape 形状
Shape Builder 形状生成器
Shape Builder Regions 形状生成器区域
@@ -6635,3 +6642,9 @@ breakText Glyphs 字形
@plural {n} earlier line dropped|{n} earlier lines dropped 已丢弃 {n} 行较早内容
@plural {n} line cut|{n} lines cut 已截断 {n} 行
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后
# Deferred plug-in effects
Refresh Deferred Effects 刷新延迟的效果
Deferred Effects 延迟的效果
@msg no plug-in effect is deferred 没有延迟的增效工具效果
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4135,6 +4135,13 @@ digits Malayalam 馬來亞拉姆數字
Quest Outline 任務
Coin Shine 金幣
Boss Alert 首領
Status Effects 狀態效果
Poison Tint 中毒
Freeze Edge 冰凍
Burn Aura 灼燒
Stun Pulse 暈眩
Stealth Fade 隱身
Shield Buff 護盾
Shape 形狀
Shape Builder 形狀建立
Shape Builder Regions 形狀建立區域
@@ -6433,3 +6440,9 @@ breakText Glyphs 字符
@plural {n} earlier line dropped|{n} earlier lines dropped 已捨棄 {n} 行較早內容
@plural {n} line cut|{n} lines cut 已截斷 {n} 行
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後
# Deferred plug-in effects
Refresh Deferred Effects 重新整理延後的效果
Deferred Effects 延後的效果
@msg no plug-in effect is deferred 沒有延後的增效模組效果
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 開啟時延後了 {n} 個增效模組效果。效果 › 重新整理延後的效果可執行它們。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -136,6 +136,7 @@ pub(super) fn effect_menu() -> Vec<Item> {
c("Last Effect…", "effect.last"),
Sep,
c("Document Raster Effects Settings…", "document.rasterEffectsSettings"),
c("Refresh Deferred Effects", "effect.refreshDeferred"),
Sep,
Item::Header("Vector Effects"),
];
+4 -1
View File
@@ -24,6 +24,9 @@ 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 document's deferred live-effect plug-in stages and first-view budget: the render runs
/// inside their view ([`vectorcraft_plugins::deferred::Deferrals::view`]).
pub effects: vectorcraft_plugins::deferred::Deferrals,
/// 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,
@@ -71,7 +74,7 @@ impl Worker {
job = newer;
}
let t0 = crate::now_ms();
let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job));
let frame = job.scope.clone().run(|| job.effects.view(|| 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() {
+12 -13
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. |
@@ -89,7 +90,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) |
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) |
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 9–19 | no | [target-app-parity.md](target-app-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 7–17 | no | [target-app-parity.md](target-app-parity.md) |
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) |
| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) |
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) |
@@ -211,19 +212,14 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
art with strokes, gradients or patterns) and type with an Art brush keep the drawn look.
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a
path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form
of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art
brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form
VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks,
clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that
qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art
([file-format-parity.md](file-format-parity.md)). Save As
`.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification,
keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and
of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art
brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope
(undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As
`.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification
instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and
images, and type as editable text objects (or outlines), one artboard per file (an artboard picker chooses
which); transparency, live effects and object names don't survive it (transparent images are flattened or
omitted, hidden objects go to a hidden layer, are omitted or made visible), and type is set again in the
@@ -648,14 +644,15 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend
(`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`,
Eyedropper), for that instance only;
graphic style libraries (Window › Graphic Style Libraries; seven original libraries generated in code, including
Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other
graphic style libraries (Window › Graphic Style Libraries; eight original libraries generated in code, including
Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects
with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other
Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`).
- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic
- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic
Shapes, UI Frames, Map Markers and Inventory Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write;
Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the
start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge).
- **Estimate:** 7–17 h.
- **Estimate:** 6–16 h.
## G3 3D and Materials
@@ -671,6 +668,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
- **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate.
- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one, swapped in a view turned 90°; both clicks on that ruler) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed.
- **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen.
- **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged.
- **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged.
@@ -854,6 +852,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. |
| 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. |
| 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. |
| 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. |
+37 -5
View File
@@ -588,13 +588,19 @@ the stored fill, or the stored stroke when no fill is stored and that stroke has
outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for
`paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. 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 before the command and has none after it does not change the paint 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` and the recolor commands do
not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and
`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store
them.
`text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes
it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that
kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`,
`categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend
leaves room for a legend label's stored indents, spacing and leading.
`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.
@@ -3022,7 +3028,7 @@ as a value left out.
## Graphic style libraries
Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and
Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD). `graphicStyle.libraries` lists them
Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them
(`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's
styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? |
names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the
@@ -3262,7 +3268,9 @@ the group holds only the art inside the clipping layer, drawn over the fill and
layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own.
`preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`,
`textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups
override it, at the top level or in `options`. An image that would be larger than 64 million pixels or longer than
32,768 pixels a side at `lineArtPpi` or `gradientPpi` is made at the highest resolution that fits, so a long area is
rasterized whole. `balance: 0` rasterizes everything; lower balances rasterize groups
that split into many regions. The result reports `{ids, vector, rasterized, options}`. When an image in the art
can't be decoded, the image that replaces the art does not show it, and the result also carries `warning`: "an image
couldn't be decoded and is missing from the picture", the note raster exports give.
@@ -3738,7 +3746,9 @@ step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect `
in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes
what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited
filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml`
empty) until a filter of that name is defined or imported again. Opened from such an effect, Apply SVG Filter shows the
empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's
filters but draws another definition keeps its own filter under a free name (`Soft 2`); pasted back into the document
it was copied from, it takes the document's filter. Opened from such an effect, Apply SVG Filter shows the
name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter
directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply
SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs;
@@ -6917,6 +6927,9 @@ With Preferences › General › Anti-aliased Artwork off, the canvas draws all
so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native
document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's
method, and opening the file restores it.
A graph label given a method keeps it for every label of its kind (value axis numbers, category labels or legend
labels) when the graph is drawn again; the graph saves it as `valuesAntiAlias`, `categoriesAntiAlias` or
`legendAntiAlias` in its label styles.
Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step.
```json
@@ -7293,7 +7306,26 @@ id `plugin.<id>`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc
`effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api`
and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them
as ordinary paths with those paints; a result it refuses is drawn as if the effect weren't there, and `plugin.info`'s
`lastError` names the plug-in and the refused field. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
`lastError` names the plug-in and the refused field. While the app draws a document on screen, the live-effect plug-ins
of its first view share a 2,000 ms budget (VectorCraft's own value), and each run also keeps to its own 5,000 ms limit:
once the budget is spent no run starts, and a result that comes back after it is not used. A stage left out for want
of time, or one that times out, fails or has no plug-in, draws its input art unchanged and is deferred; the status bar
then says how many (`Opened with 3 plug-in effects deferred. Effect › Refresh Deferred Effects runs them.`). Later
drawings leave a deferred stage as it is until its input or parameters change, which runs it under the per-call limit.
`effect.deferredStatus {}` returns `{budgetMs: 2000, effects: [{objectId, effectIndex, item?, pluginId, reason}]}` for
the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke
whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list
when none is deferred). A stage whose object is deleted, or whose plug-in effect is
removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh
Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them)
again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left
out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn,
and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that
timed out, failed or have no plug-in. `id` names one object in place of
`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand
Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only
deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first
view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
that no built-in format has. An importer adds a read-only file format `plugin.<id>` (listed by `document.formats`
with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?:
+13
View File
@@ -137,6 +137,19 @@ An export runs where the export runs: with Background Export on, on the export's
plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the
open it runs in is cancelled, and nothing opens then.
### Live effects while a document opens
While the app draws a document on screen, the live-effect runs of its first view share a 2,000 ms budget
(`vectorcraft_plugins::deferred::OPENING_BUDGET_MS`, VectorCraft's own value), measured from the first run. Each run
takes the lesser of its 5 s limit and what is left of the budget; once the budget is spent no run starts, and a result
that comes back after it is not used. A stage left out this way, or one that times out, traps, returns output the host
refuses or has no plug-in, draws its input art unchanged and is deferred. The status bar then says how many stages
are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh
Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists
them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget.
Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the
budget, so there the first view runs no live effect and Refresh Deferred Effects runs them.
### Heavy filters
Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a
+2 -1
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -189,6 +189,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged |
| 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged |
| 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. |
| 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged |
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
@@ -237,6 +238,7 @@ tools, never checked side by side) isn't captured row by row.
| Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set, including Snap to Glyph and Alignment Guides (#6164); no View › Snapping submenu. Floating-panel parity remains unverified. |
| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only |
| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides |
| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` |
| Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) |
| Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute |
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer |
@@ -507,6 +509,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). |
| 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. |
| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. |