diff --git a/Cargo.lock b/Cargo.lock index ce5569cde9..0c34a6a952 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6477,6 +6477,7 @@ dependencies = [ "vectorcraft-text", "vectorcraft-three-d", "vectorcraft-trace", + "wat", ] [[package]] diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 14f9a0d08f..6d58ad9d74 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -29,3 +29,5 @@ serde_json = { workspace = true } vectorcraft-color = { workspace = true } # A face built in code from the bundled Source Sans 3 (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } +# Test live-effect plug-ins written in WebAssembly text. +wat = { workspace = true } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c5aaed7309..89a258bb07 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1901,22 +1901,30 @@ fn paint_leaf( /// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most /// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths. fn piece_cost(path: &PathData) -> Budget { + Budget::of(path).plus(piece_share()) +} + +/// The share of the effects' budget each painted piece costs besides its path ([`piece_cost`]). +fn piece_share() -> Budget { let max = vectorcraft_plugins::objects::MAX_OBJECTS; - Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max }) + Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max } } /// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of -/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference -/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that -/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first -/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's -/// other fills and strokes; one that gives none keeps the leaf's. Returns +/// `item`, run on `leaf`, a path of [`transform_piece`] painted by `item` (its first fill or stroke), +/// with reference box `bounds`: `leaf` becomes a group of one path for each piece returned, in +/// order. The plug-in is given `appearance`'s fills and strokes and told that it is on item `index` +/// of them. A piece that gives fills (on a fill) or strokes (on a stroke) is painted by copies of +/// `item` with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's 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`. A deferred run is listed by `at`. +#[allow(clippy::too_many_arguments)] fn paint_piece( leaf: &mut Node, item: &AppearanceItem, + (appearance, index): (&Appearance, usize), (plugin, params): (&str, &serde_json::Value), bounds: Rect, budget: Budget, @@ -1924,7 +1932,7 @@ fn paint_piece( at: Option, ) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(appearance), item: Some(index), 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) { @@ -2093,7 +2101,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, i, &kept), crate::raster::held_back(before), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), true, limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -2116,7 +2124,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt if moved && !held_later { follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); } - transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), false, limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -2129,7 +2137,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt leaf.kind = NodeKind::Group { children: pieces, clip: false }; } -/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a +/// Apply the visible geometry effects left on the fills or strokes 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_carrying`]). Each path applies its own copy of @@ -2138,19 +2146,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// the effects after it, and with Transform Objects off those before it, as they are /// ([`keep_listed`]). /// 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 raster effects before a Transform effect -/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before -/// the piece's effects that makes copies or moves at random holds back, and the effects before -/// such a Transform effect among the piece's effects. The geometry effects then +/// for each piece it returns ([`paint_piece`]). A path with several fills or strokes (those a +/// painted plug-in returned) takes such an effect, a live-effect plug-in or a geometry effect on +/// strokes once for each of them, in paint order, given its own place among them +/// ([`shares_stage`]): each run's pieces replace only that fill or stroke, and a run that fails or +/// would pass the budget leaves that fill or stroke on the path as it was. The raster effects +/// before a Transform effect follow it ([`follow_piece`]), except the first `held` effects, which +/// a Transform effect before the piece's effects that makes copies or moves at random holds back, +/// and the effects before such a Transform effect among the piece's effects. 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`. `(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]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// the piece kept, which a deferred live-effect plug-in is listed by. `painted`: `p`'s paths are +/// the pieces of a painted run, each of which `used` counts at [`piece_cost`]. +fn transform_piece( + p: &mut Node, + (seed, source, kept): (u64, usize, &[usize]), + held: usize, + painted: bool, + 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() { + for item in &l.appearance.items { count = count.max(item.effects().len()); } }); @@ -2158,60 +2179,207 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h // random are held back from the Transform effects before it too. let mut held = held; for_each_leaf(p, &mut |l| { - if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { - held = held.max(last); + for item in &l.appearance.items { + if let Some(last) = item.effects().iter().rposition(crate::raster::holds_back) { + held = held.max(last); + } } }); + // Whether `used` counts each path under `p`, in the order `for_each_leaf` visits them, at + // [`piece_cost`] (its geometry and a painted piece's share) rather than at its geometry alone. + // The pieces of a painted run are counted so, and so is the first path an effect makes of one + // that was: the paths of a Transform effect's copies share their original's share. + let mut shares = vec![painted; leaf_count(p)]; for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; let from = held.min(j); let mut holds = false; - for_each_leaf(p, &mut |leaf| { - let Some(item) = leaf.appearance.items.first().cloned() else { return }; - let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; - holds |= crate::raster::holds_back(&e); - // The effects before this one as they were before it moved the path. - let was = item.effects().get(from..j).unwrap_or_default(); - let Some((path, rule)) = leaf_geometry(leaf) else { return }; - // 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, Some(stage)) { - *used = used.minus(before).plus(made); - } - return; - } - if reaches_appearance(&e) { - let params = merged_params(&e.id, &e.params); - let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let keep = |k: usize| k > j || (k < j && !options.objects); - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep))); + let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; + let at = PieceStage { j, from, seed, bounds, doc, stage }; + // Stage `j` on `leaf`, a path `used` counts at `piece_cost` when `share`: whether `used` + // counts each path it became so, or `None` when it stays as it was. + let mut on_leaf = |leaf: &mut Node, share: bool| -> Option> { + let effect = |item: &AppearanceItem| item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned(); + let items = &leaf.appearance.items; + let first = items.first()?; + let (path, _) = leaf_geometry(leaf)?; + // What `used` counts the path at; what the other paths leave of the budget is the rest. + let geometry = Budget::of(&path); + let counted = if share { piece_cost(&path) } else { geometry }; + if shares_stage(items, j) { + let e = effect(first)?; + holds |= crate::raster::holds_back(&e); + let appearance = leaf.appearance.clone(); + // A painted run's pieces each cost a share of their own ([`paint_piece`]); the + // paths of any other effect keep the leaf's. + let painted = painted_plugin(&e).is_some(); + let before = if painted { counted } else { geometry }; + let room = max.minus(used.minus(before)); + let made = piece_stage(leaf, &e, &at, (&appearance, 0), room)?; *used = used.minus(before).plus(made); - if moved { - follow_piece(leaf, was, from, &e); - } - return; + return Some((0..leaf_count(leaf)).map(|k| painted || (share && k == 0)).collect()); } + // Each fill or stroke takes the effect on its own path, given the leaf's fills and + // strokes and its own place among them; those left with the same path as the one + // before them stay on it. Each path costs its geometry and a painted piece's share. + // Each run is first given all the room the art leaves. When a fill or stroke that keeps + // the leaf's path then finds no room for its copy of it, the results are placed again, + // each but the last leaving room for one more copy of the leaf's own path; a result + // that does not fit that room is left out. Only a fill or stroke that keeps the leaf's + // path right after one that changed costs such a copy (the ones after it stay on that + // copy), so every copy fits but the first fill's or stroke's, when the art leaves no + // room for it: the leaf then stays as it was. A live-effect plug-in is given all the + // room the art leaves, and each placing checks that its result fits; a built-in effect + // builds within the room of each placing. let appearance = leaf.appearance.clone(); - let stroke = match &item { - AppearanceItem::Stroke(s) => Some(s), - AppearanceItem::Fill(_) => None, + let one = piece_cost(&path); + let n = appearance.items.len(); + let base = used.minus(counted); + let alone = |item: &AppearanceItem| { + let mut sub = leaf.clone(); + sub.appearance.items = vec![item.clone()]; + sub }; - 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)); - // The fill's or stroke's gradient follows the effect as the object's does. - if carry.moves(&leaf.appearance) { - carry.apply(&mut leaf.appearance); - } - set_geometry(leaf, out, rule); - follow_piece(leaf, was, from, &e); + // `item`, the `k`th fill or stroke, alone on the leaf's path with effect `e` applied + // within `room`, and what its paths cost; `None` when it failed or found no room. + let apply = |item: &AppearanceItem, k: usize, e: &Effect, room: Budget| { + let painted = painted_plugin(e).is_some(); + let room = if painted { room } else { room.minus(piece_share()) }; + let mut sub = alone(item); + let made = piece_stage(&mut sub, e, &at, (&appearance, k), room)?; + Some((sub, if painted { made } else { made.plus(piece_share()) })) + }; + // Each fill's or stroke's live-effect plug-in run. A result is kept only while a + // placing places it on a path of its own; one that does not fit is dropped, with what + // it cost, and the plug-in runs again in the second placing only when that much room + // is left there (a result small enough to cache comes back from the cache). A result + // with the same path as the one before it costs nothing: it is placed on that path + // whatever room is left, and dropped once its fill or stroke is moved there. The + // results kept at once cost at most what the two placings place. + let mut runs: Vec = vec![PluginRun::Not; n]; + let mut kept_cost = Budget { anchors: 0, lines: 0 }; + // Each placing gives the paths, whether `used` counts each of the paths they hold at + // `piece_cost` (all of a painted run's, and the first of any other result's), and what + // the art then spends. + let mut split = |reserve: bool| -> Option<(Vec, Vec, Budget)> { + let mut spent = base; + let mut out: Vec = vec![]; + let mut counts: Vec = vec![]; + for (k, (item, ran)) in appearance.items.iter().zip(runs.iter_mut()).enumerate() { + let room = if reserve && k + 1 < n { max.minus(spent).minus(one) } else { max.minus(spent) }; + let e = effect(item); + let paints = e.as_ref().is_some_and(|e| painted_plugin(e).is_some()); + // Does `sub` stay on the path of the result before it, at no cost? + let merges = |sub: &Node| out.last().is_some_and(|last| same_path(last, sub)); + // What the kept plug-in result this fill or stroke takes cost. + let mut taken = None; + let result = match e { + None => None, + Some(e) => { + holds |= crate::raster::holds_back(&e); + if vectorcraft_plugins::effect::plugin_id(&e.id).is_some() { + let again = match ran { + PluginRun::Not | PluginRun::Merged => true, + PluginRun::Left(cost) => room.fits(*cost), + PluginRun::Kept(..) | PluginRun::Failed => false, + }; + if again { + #[cfg(test)] + PLUGIN_RUNS.with(|n| n.set(n.get() + 1)); + *ran = match apply(item, k, &e, max.minus(base)) { + None => PluginRun::Failed, + Some((sub, cost)) if !room.fits(cost) && !merges(&sub) => PluginRun::Left(cost), + Some((sub, cost)) => { + kept_cost = kept_cost.plus(cost); + PluginRun::Kept(sub, cost) + } + }; + } + match ran { + PluginRun::Kept(sub, cost) if room.fits(*cost) || merges(sub) => { + taken = Some(*cost); + Some((sub.clone(), *cost)) + } + PluginRun::Kept(_, cost) => { + let cost = *cost; + kept_cost = kept_cost.minus(cost); + *ran = PluginRun::Left(cost); + None + } + PluginRun::Not | PluginRun::Merged | PluginRun::Left(_) | PluginRun::Failed => None, + } + } else { + // A built-in effect that finds no room within `room` leaves the + // leaf's path. When its result is not on the path before it, it + // is built again with all the room the art leaves and taken only + // when that result is on the path before it, which costs nothing. + let made = apply(item, k, &e, room); + let short = match &made { + None => true, + Some((sub, cost)) => !merges(sub) && (!room.fits(*cost) || same_path(sub, leaf)), + }; + match out.last() { + Some(_) if short => match apply(item, k, &e, max.minus(base)) { + Some((sub, cost)) if merges(&sub) => Some((sub, cost)), + _ => made, + }, + _ => made, + } + } + } + }; + let placed = match result { + Some((sub, cost)) if room.fits(cost) || merges(&sub) => Some((sub, cost, paints)), + _ => None, + }; + // A fill or stroke whose result is not placed stays on the leaf's path, which + // is copied only when the path before it is another. + let (mut sub, cost, all) = match placed { + Some(placed) => placed, + None => match out.last_mut() { + Some(last) if same_path(last, leaf) => { + last.appearance.items.push(item.clone()); + continue; + } + _ => (alone(item), one, true), + }, + }; + match out.last_mut() { + Some(last) if same_path(last, &sub) => { + last.appearance.items.append(&mut sub.appearance.items); + if let Some(cost) = taken { + kept_cost = kept_cost.minus(cost); + *ran = PluginRun::Merged; + } + } + _ if !max.minus(spent).fits(cost) => return None, + _ => { + spent = spent.plus(cost); + counts.extend((0..leaf_count(&sub)).map(|k| all || k == 0)); + out.push(sub); + } + } + #[cfg(test)] + HELD_PEAK.with(|p| p.set(p.get().at_least(kept_cost))); + } + Some((out, counts, spent)) + }; + let (mut out, counts, spent) = split(false).or_else(|| split(true))?; + *used = spent; + *leaf = match out.len() { + 1 => out.pop().unwrap_or_else(|| leaf.clone()), + _ => Node::group(leaf.id, out.into_iter().map(Arc::new).collect()), + }; + Some(counts) + }; + let mut next: Vec = vec![]; + let mut visited = 0; + for_each_leaf(p, &mut |leaf| { + let share = shares.get(visited).copied().unwrap_or(false); + visited += 1; + next.extend(on_leaf(leaf, share).unwrap_or_else(|| vec![share])); }); + shares = next; if holds { held = held.max(j); } @@ -2223,6 +2391,134 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h }); } +/// A fill's or stroke's live-effect plug-in run in a placing of [`transform_piece`]: not run yet, +/// its result and what that costs while a placing places it on a path of its own, what a result +/// that did not fit cost (the result itself is dropped), a result dropped once its fill or stroke +/// was placed on the path before it, or failed. +#[derive(Clone)] +enum PluginRun { + Not, + Kept(Node, Budget), + Left(Budget), + Merged, + Failed, +} + +#[cfg(test)] +thread_local! { + /// The most the live-effect plug-in results [`transform_piece`] keeps from one fill or stroke + /// to the next have cost at once on this thread. + pub(crate) static HELD_PEAK: std::cell::Cell = const { std::cell::Cell::new(Budget { anchors: 0, lines: 0 }) }; + /// How many times [`transform_piece`] has run a fill's or stroke's live-effect plug-in on this + /// thread when they did not share the stage. + pub(crate) static PLUGIN_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Where a stage of [`transform_piece`] runs: `j`, the index of its effect among the piece's +/// effects; `from`, the first of the effects before it whose raster effects follow a Transform +/// effect; the object's `seed`; the reference box `bounds` of all the piece's paths; the document +/// that painted pieces' swatches and patterns must be in; and the `stage` a deferred live-effect +/// plug-in is listed by. +struct PieceStage<'a> { + j: usize, + from: usize, + seed: u64, + bounds: Rect, + doc: Option<&'a Document>, + stage: vectorcraft_plugins::deferred::Stage, +} + +/// Can stage `j` of [`transform_piece`] run once for all of a path's `items`: there is at most one, +/// or they hold the same effects and the effect at `j` is not a visible geometry effect, is a +/// Transform effect, or is a built-in geometry effect and they are all fills (an effect that +/// changes a fill's path does not depend on which fill it is on)? +fn shares_stage(items: &[AppearanceItem], j: usize) -> bool { + let Some(first) = items.first() else { return true }; + if items.len() == 1 { + return true; + } + if !items.iter().all(|i| i.effects() == first.effects()) { + return false; + } + match first.effects().get(j) { + Some(e) if e.visible && is_geometry(&e.id) => { + vectorcraft_plugins::effect::plugin_id(&e.id).is_none() && (reaches_appearance(e) || items.iter().all(AppearanceItem::is_fill)) + } + _ => true, + } +} + +/// Effect `e`, a visible geometry effect at stage `at` of [`transform_piece`], applied to `leaf`, a +/// path painted by its first fill or stroke (and, when they share the stage, [`shares_stage`], the +/// others): `appearance` is what the effect is given of the path's fills and strokes and `index` +/// which of them it is on. A live-effect plug-in that paints what it returns makes `leaf` a group +/// of its pieces ([`paint_piece`]), and a Transform effect that reaches its appearance transforms +/// it ([`transform_leaf`]). Returns what the new paths cost, within `room`; `None` when `leaf` +/// stays as it is. +fn piece_stage(leaf: &mut Node, e: &Effect, at: &PieceStage, (appearance, index): (&Appearance, usize), room: Budget) -> Option { + let item = leaf.appearance.items.first()?.clone(); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(at.from..at.j).unwrap_or_default(); + let (path, rule) = leaf_geometry(leaf)?; + if let Some(plugin) = painted_plugin(e) { + let params = merged_params(&e.id, &e.params); + return paint_piece(leaf, &item, (appearance, index), (plugin, ¶ms), at.bounds, room, at.doc, Some(at.stage)); + } + if reaches_appearance(e) { + let params = merged_params(&e.id, &e.params); + let (_, copies) = crate::distort::transform_map(at.bounds, ¶ms, at.seed); + let options = crate::distort::TransformOptions::of(¶ms); + let map = crate::distort::transform_affine(at.bounds, ¶ms, at.seed); + let j = at.j; + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep))); + if moved { + follow_piece(leaf, was, at.from, e); + } + return Some(made); + } + let stroke = match &item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let ctx = GeomContext { stroke, rule, seed: at.seed, appearance: Some(appearance), item: Some(index), doc: at.doc, stage: Some(at.stage) }; + let mut carry = crate::Carry::default(); + let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(e), &path, at.bounds, &ctx, room, Some(&mut carry)); + let made = Budget::of(&out); + // The fill's or stroke's gradient follows the effect as the object's does. + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } + set_geometry(leaf, out, rule); + follow_piece(leaf, was, at.from, e); + Some(made) +} + +/// Are `a` and `b` paths or compound paths that differ only in their fills and strokes? +fn same_path(a: &Node, b: &Node) -> bool { + let leaf = |n: &Node| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + if !leaf(a) || !leaf(b) { + return false; + } + // Every field but the fills and strokes, compared in place; the geometry last. + let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a; + let Appearance { items: _, effects, contents_index } = &**appearance; + *id == b.id + && *name == b.name + && *visible == b.visible + && *locked == b.locked + && *opacity == b.opacity + && *blend == b.blend + && *isolate == b.isolate + && *knockout == b.knockout + && *knockout_shape == b.knockout_shape + && *bbox_angle == b.bbox_angle + && *extras == b.extras + && *effects == b.appearance.effects + && *contents_index == b.appearance.contents_index + && *kind == b.kind +} + /// The effects a Transform effect in a fill's or stroke's own list leaves as they were /// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its /// indices keep their values. `None` for an object's own Transform effect. diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 56f9d40633..48665539d9 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; mod imageoutline; +mod painted; mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/painted.rs b/crates/effects/src/tests/painted.rs new file mode 100644 index 0000000000..31dadc865e --- /dev/null +++ b/crates/effects/src/tests/painted.rs @@ -0,0 +1,270 @@ +//! Live-effect plug-ins that paint what they return, run on each fill a painted effect returned. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath, shapes}; +use vectorcraft_plugins::wat as tpl; + +use crate::geometry::Budget; +use crate::reshape; +use crate::reshape::{HELD_PEAK, PLUGIN_RUNS}; + +/// Installs live-effect plug-in `id`, which paints what it returns, replacing one with the same id: +/// its effect returns `output` whatever it is given. +fn install_painting(id: &str, output: &serde_json::Value) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// An open path of `curves` tall curves, each drawn with about 5,200 lines. +fn tall_curves(curves: u32) -> PathData { + let h = 3_000_000.0; + let anchors = (0..=curves) + .map(|i| { + let x = 40.0 + f64::from(i); + Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + PathData::single(SubPath::new(anchors, false)) +} + +/// Installs live-effect plug-in `id`, which reshapes the path it is given: its effect returns +/// `output` whatever it is given. +fn install_reshaping(id: &str, output: &serde_json::Value) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// `curves` curves from `x`, each `h` tall at its handles. +fn curves_at(x: f64, curves: u32, h: f64) -> SubPath { + let anchors = (0..=curves) + .map(|i| { + let x = x + f64::from(i); + Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + SubPath::new(anchors, false) +} + +/// An open path, `dx` to the right of 40, drawn with about `lines` lines: tall curves and one +/// curve that makes up the rest. +fn drawn_with(lines: usize, dx: f64) -> PathData { + let h = 3_000_000.0; + let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines; + let tall = u32::try_from(lines / per).unwrap(); + let rest = lines % per; + let mut subpaths = vec![curves_at(40.0 + dx, tall, h)]; + if rest > 0 { + // A curve `h` tall at its handles is drawn with about √(9h) lines. + let rest = rest as f64; + subpaths.push(curves_at(40.0 + dx + f64::from(tall) + 2.0, 1, rest * rest / 9.0)); + } + PathData::new(subpaths) +} + +/// Stacks a painted effect that returns `first` on a path drawn with about `lines` lines with five +/// later effects on a square's fill, the `k`th returning that path `k` to the right (`later` +/// installs each). Each piece's share of the budget is counted once, so every effect fits, and the +/// art ends on the last effect's path, painted by `paints`. +fn later_effects_count_each_piece_once(name: &str, lines: usize, first: &serde_json::Value, later: fn(&str, &serde_json::Value), paints: &[Paint]) { + let near = drawn_with(lines, 0.0); + let drawn = Budget::of(&near).lines; + assert!(drawn.abs_diff(lines) <= 2, "the path is drawn with {drawn} lines"); + let first_id = format!("test.effects.{name}.first"); + let mut first = first.clone(); + first["objects"][0]["path"] = serde_json::to_value(&near).unwrap(); + install_painting(&first_id, &first); + let mut ids = vec![format!("plugin.{first_id}")]; + for k in 1..=5u8 { + let id = format!("test.effects.{name}.later{k}"); + later(&id, &json!({"objects": [{"path": drawn_with(lines, f64::from(k))}]})); + ids.push(format!("plugin.{id}")); + } + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for id in ids { + n.appearance.items[fill].effects_mut().push(Effect { id, params: json!({}), visible: true }); + } + let art = reshape(None, &n, None).unwrap(); + let mut paths = vec![]; + // The path is a compound path of two (the tall curves and the one that makes up the rest). + art.walk(&mut |c| { + let bounds = match &c.kind { + NodeKind::Compound { children, .. } => children.iter().filter_map(|k| k.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)), + _ => c.path_data().and_then(PathData::bounds), + }; + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if bounds.is_some() && !paints.is_empty() { + paths.push((bounds, paints)); + } + }); + assert_eq!(paths, [(drawn_with(lines, 5.0).bounds(), paints.to_vec())]); +} + +/// Later geometry effects on the two fills a painted effect returned run on each fill, and the +/// path they both stay on counts its piece's share of the budget once: each fill's result, drawn +/// with about 200 lines fewer than half the budget allows, fits at every stage. +#[test] +fn later_geometry_effects_on_two_fills_count_the_piece_once() { + let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))]; + let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect(); + let lines = Budget::EFFECTS.lines / 2 - 200; + later_effects_count_each_piece_once("twofills", lines, &json!({"objects": [{"fills": fills}]}), install_reshaping, &colors); +} + +/// Later painted effects on a painted effect's piece count the piece's share of the budget once: +/// each run's pieces take the place of the piece they were made of, so a result drawn with about +/// 300 lines fewer than the budget allows fits at every stage. +#[test] +fn later_painted_effects_on_a_painted_piece_count_it_once() { + let lines = Budget::EFFECTS.lines - 300; + later_effects_count_each_piece_once("painted", lines, &json!({"objects": [{}]}), install_painting, &[Paint::solid(Color::BLACK)]); +} + +/// A path's bounds and the paints of its fills and strokes. +type PaintedPath = (Option, Vec); + +/// The paths of the art a painted effect `name` that returns `square` with eight fills makes, each +/// path with its paints, when a later effect returns `first` on the first fill and `rest` on the +/// others, painting what it returns when `paints`; and the eight paints. Checks that the plug-in +/// results kept from one fill to the next never cost more than the effects' budget together. +fn eight_fills_then(name: &str, square: &PathData, paints: bool, results: (&PathData, &PathData)) -> (Vec, Vec) { + let (paths, colors, _) = fills_then(name, 8, square, paints, results); + (paths, colors) +} + +/// [`eight_fills_then`] with `n` fills, and how many times the later effect ran on a fill. +fn fills_then(name: &str, n: u8, square: &PathData, paints: bool, (first, rest): (&PathData, &PathData)) -> (Vec, Vec, usize) { + let colors: Vec = (0..n).map(|i| Paint::solid(Color::rgb(f32::from(i) / f32::from(n), 0.0, 0.0))).collect(); + let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect(); + let fills_id = format!("test.effects.{name}.fills"); + install_painting(&fills_id, &json!({"objects": [{"path": square, "fills": fills}]})); + let each_id = format!("test.effects.{name}.each"); + let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" }; + let manifest = format!(r#"{{"id":"{each_id}","name":"Test {each_id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#); + let (on_first, on_rest) = (json!({"objects": [{"path": first}]}).to_string(), json!({"objects": [{"path": rest}]}).to_string()); + let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_rest)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for id in [fills_id, each_id] { + n.appearance.items[fill].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + } + HELD_PEAK.with(|p| p.set(Budget { anchors: 0, lines: 0 })); + PLUGIN_RUNS.with(|r| r.set(0)); + let art = reshape(None, &n, None).unwrap(); + let runs = PLUGIN_RUNS.with(std::cell::Cell::get); + let mut paths = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if !paints.is_empty() { + paths.push((p.bounds(), paints)); + } + } + }); + let peak = HELD_PEAK.with(std::cell::Cell::get); + assert!(Budget::EFFECTS.fits(peak), "the results kept cost {peak:?}"); + (paths, colors, runs) +} + +/// A later painted effect's results on eight fills, each costing more than half the effects' +/// budget and the first fill's on another path than the rest: only the first fill's result fits, +/// the other fills stay together on the square, and the results kept never cost more than the +/// budget together. +#[test] +fn painted_results_that_do_not_fit_are_not_kept() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let (large, other) = (tall_curves(900), PathData::single(curves_at(3_000.0, 900, 3_000_000.0))); + let (paths, colors) = eight_fills_then("held", &square, true, (&large, &other)); + assert_eq!(paths, [(large.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]); +} + +/// A later effect that reshapes each of eight painted fills to the same path, drawn with about a +/// fifth of the effects' budget: the fills share that one path, and the results that move onto it +/// are not kept as well, so what is kept never costs more than the budget. +#[test] +fn results_on_one_path_are_not_kept_for_each_fill() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let same = tall_curves(300); + let (paths, colors) = eight_fills_then("merged", &square, false, (&same, &same)); + assert_eq!(paths, [(same.bounds(), colors)]); +} + +/// A later effect that reshapes each of eight painted fills to the same path, drawn with more than +/// half the effects' budget: the path costs the art once, so every fill moves onto it, although a +/// second copy of it would not fit. +#[test] +fn results_on_one_path_share_it_near_the_budget() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let large = tall_curves(900); + let (paths, colors) = eight_fills_then("shared", &square, false, (&large, &large)); + assert_eq!(paths, [(large.bounds(), colors)]); +} + +/// An open path of tall curves `dx` to the right of 40, drawn with about `share` of the effects' +/// budget's lines. +fn drawn_share(share: f64, dx: f64) -> PathData { + let h = 3_000_000.0; + let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines as f64; + let curves = (Budget::EFFECTS.lines as f64 * share / per) as u32; + PathData::single(curves_at(40.0 + dx, curves, h)) +} + +/// A later painted effect runs once on each of three fills although the results are placed twice: +/// a result that fits both placings is kept between them, and one that did not fit in the first is +/// not run again when it does not fit the second either. The first fill's result (a tenth of the +/// budget) and the second's (half of it) fit, the third's (half of it) does not, and the square +/// (45%) left for the third fill then has no room; in the second placing the second fill's result +/// does not fit beside the square kept for the fills after it. +#[test] +fn a_later_plug_in_runs_once_on_each_fill_across_both_placings() { + let (square, first, rest) = (drawn_share(0.45, 0.0), drawn_share(0.1, 2_000.0), drawn_share(0.5, 4_000.0)); + let (paths, colors, runs) = fills_then("once", 3, &square, true, (&first, &rest)); + assert_eq!(paths, [(first.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]); + assert_eq!(runs, 3, "the later effect ran {runs} times on three fills"); +} + +/// A built-in geometry effect on two strokes that a painted effect returned, which doubles the lines +/// of a path drawn with 30% of the budget: the second stroke's result does not fit beside the +/// first's, but it is the same path, so it is built again with all the room the art leaves and +/// both strokes are drawn on that one path. +#[test] +fn a_built_in_result_on_the_path_before_it_is_taken_near_the_budget() { + let square = drawn_share(0.3, 0.0); + let transform = json!({"scaleV": 400}); + let tall = crate::distort::transform_within(&square, square.bounds().unwrap(), &transform, 0, usize::MAX); + let (s, t) = (Budget::of(&square).lines, Budget::of(&tall).lines); + let lines = Budget::EFFECTS.lines; + assert!(t > lines / 2 + lines / 20 && t < lines - lines / 20 && s + t < lines, "the paths are drawn with {s} and {t} lines"); + let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))]; + let strokes: Vec<_> = colors.iter().map(|p| json!({"paint": p, "width": 1})).collect(); + let id = "test.effects.builtin.strokes"; + install_painting(id, &json!({"objects": [{"path": square, "strokes": strokes}]})); + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let stroke = n.appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + n.appearance.items[stroke].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + n.appearance.items[stroke].effects_mut().push(Effect { id: "distort.transform".into(), params: transform, visible: true }); + let art = reshape(None, &n, None).unwrap(); + let mut paths = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if !paints.is_empty() { + paths.push((p.bounds(), paints)); + } + } + }); + // One path, scaled about a reference point of the piece's own, painted by both strokes. + assert_eq!(paths.len(), 1, "{paths:?}"); + let (bounds, paints) = &paths[0]; + assert_eq!(paints, &colors.to_vec()); + let height = |b: Option| b.map_or(0.0, |b| b.height()); + assert!((height(*bounds) - height(tall.bounds())).abs() < 1.0, "{bounds:?} is not {:?} tall", tall.bounds()); +} diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 2661e85787..499f2d15c6 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -796,30 +796,269 @@ fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() { assert_eq!(patterns, 0, "the tiles are objects"); } -/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces -/// the first of them and the other fill stays over it. +/// A path's bounds and the paints of its fills and strokes, each stroke with its weight. +type PaintedPath = (Option, Vec<(Paint, Option)>); + +/// The paths of `id`'s reshaped art that paint, in paint order, each with the fills and strokes of +/// it that paint. +fn painted_paths(s: &Session, id: NodeId) -> Vec { + let st = s.doc().unwrap(); + let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); + let mut paths = vec![]; + art.walk(&mut |n| { + if let Some(p) = n.path_data() { + let items: Vec<_> = n + .appearance + .items + .iter() + .map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => (f.paint.clone(), None), + vectorcraft_doc::AppearanceItem::Stroke(st) => (st.paint.clone(), Some(st.width)), + }) + .filter(|(paint, _)| !paint.is_none()) + .collect(); + if !items.is_empty() { + paths.push((p.bounds(), items)); + } + } + }); + paths +} + +fn paint(hex: &str) -> Paint { + Paint::solid(Color::from_hex(hex).unwrap()) +} + +/// Two painted effects on one fill, the first returning a piece with two fills: the second runs on +/// each of them, told which fill it is on. On the second fill it returns a blue square at 80 pt; on +/// the first it returns a result that is refused, which leaves only that fill, red, on the square at +/// 40 pt. #[test] -fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() { +fn a_later_painted_effect_runs_on_every_fill_a_piece_returns() { let mut s = session(); let id = rect(&mut s, 10.0, "#ff0000"); let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); - let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two)); - install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.onsecond", true), tpl::Given::Params, r#""item":{"fill":1}"#, &blue)); let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twofills", "item": fill})); - run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill})); - assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one"); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onsecond", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (RED, BLUE), "the first fill as it was, the second moved and blue"); + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#ff0000"), None)]), (b, vec![(paint("#0000ff"), None)])]); + // The same result for both fills: each is replaced. + let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.onsecond", true), &one)); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#0000ff"), None)]), (a, vec![(paint("#0000ff"), None)])]); +} + +/// On a stroke, a later painted effect runs on each stroke the first returns, with its own weight +/// and place among them, and keeps their order. +#[test] +fn a_later_painted_effect_runs_on_every_stroke_a_piece_returns() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"})); + let two = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#ff0000"), "width": 2}, {"paint": solid("#00ff00"), "width": 6}]}]}) + .to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "strokes": [{"paint": solid("#0000ff"), "width": 4}]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twostrokes", true), &two)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.onsecondstroke", true), tpl::Given::Params, r#""item":{"stroke":1}"#, &blue)); + let stroke = node(&s, id).appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.twostrokes", "plugin.test.engine.onsecondstroke"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": stroke})); + } + let stroked: Vec<_> = painted_paths(&s, id).into_iter().filter(|(_, items)| items.iter().any(|(_, w)| w.is_some())).collect(); + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(stroked, [(a, vec![(paint("#ff0000"), Some(2.0))]), (b, vec![(paint("#0000ff"), Some(4.0))])]); +} + +/// A third painted effect runs on every fill the second returned, for each fill the first returned. +#[test] +fn a_third_painted_effect_visits_every_fill_from_the_second() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let first = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let second = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff"), solid("#00ffff")]}]}).to_string(); + let third = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#ffff00")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.third.a", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.third.b", true), &second)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.third.c", true), tpl::Given::Params, r#""item":{"fill":1}"#, &third)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["a", "b", "c"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.test.engine.third.{effect}"), "item": fill})); + } + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + let pair = [(a, vec![(paint("#0000ff"), None)]), (b, vec![(paint("#ffff00"), None)])]; + assert_eq!(painted_paths(&s, id), [pair.clone(), pair].concat()); +} + +/// A later live effect that changes geometry only runs on each fill a painted effect returned, told +/// which fill it is on: on the second it moves the square to 80 pt, and the first stays at 40 pt. +#[test] +fn a_later_geometry_effect_uses_each_generated_fill_s_context() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let moved = json!({"objects": [{"path": square_at(80.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.geomfills", true), &two)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.geomsecond", false), tpl::Given::Params, r#""item":{"fill":1}"#, &moved)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.geomfills", "plugin.test.engine.geomsecond"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#ff0000"), None)]), (b, vec![(paint("#00ff00"), None)])]); +} + +/// A later Outline Stroke effect runs on each stroke a painted effect returned, with that stroke's +/// weight: the 2 pt stroke's outline is 22 pt wide and the 6 pt stroke's 26 pt, in their order. +#[test] +fn a_later_outline_stroke_uses_each_generated_stroke_s_weight() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"})); + let two = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#ff0000"), "width": 2}, {"paint": solid("#00ff00"), "width": 6}]}]}) + .to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.outlinestrokes", true), &two)); + let stroke = node(&s, id).appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.outlinestrokes", "item": stroke})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "path.outlineStroke", "params": {}, "item": stroke})); let st = s.doc().unwrap(); let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); - let mut paints = vec![]; + // Each outline is a compound path (the ring around the square): its width and its strokes. + let mut outlines = vec![]; art.walk(&mut |n| { - if n.path_data().is_some() { - paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::>()); + if let vectorcraft_doc::NodeKind::Compound { children, .. } = &n.kind { + let bounds = children.iter().filter_map(|c| c.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)); + let strokes: Vec<_> = n + .appearance + .items + .iter() + .filter_map(|i| match i { + vectorcraft_doc::AppearanceItem::Stroke(st) => Some((st.paint.clone(), st.width)), + vectorcraft_doc::AppearanceItem::Fill(_) => None, + }) + .collect(); + outlines.push((bounds.map(|b| (b.width() * 1000.0).round() / 1000.0), strokes)); } }); - let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap()); - assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}"); + assert_eq!( + outlines, + [(Some(22.0), vec![(paint("#ff0000"), 2.0)]), (Some(26.0), vec![(paint("#00ff00"), 6.0)])], + "each stroke is outlined at its own weight" + ); +} + +/// A later painted effect's runs on the fills a piece returned share the effects' budget: each run +/// that fits gives all its pieces, and once the budget is spent the remaining fills stay together +/// on the piece's path, with none of their run's pieces. +#[test] +fn painted_fills_share_the_budget_without_partial_results() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let fills = vec![solid("#ff0000"); vectorcraft_plugins::objects::MAX_ITEMS]; + let first = json!({"objects": [{"path": square_at(40.0), "fills": fills}]}).to_string(); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let many = json!({"objects": vec![json!({"path": tiny}); 400]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.budget.fills", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.budget.many", true), &many)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.budget.fills", "plugin.test.engine.budget.many"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let paths = painted_paths(&s, id); + let tinies = paths.iter().filter(|(b, _)| *b == tiny.bounds()).count(); + let kept: Vec<_> = paths.iter().filter(|(b, _)| *b == square_at(40.0).bounds()).collect(); + assert_eq!(kept.len(), 1, "the fills left out stay together"); + let ran = vectorcraft_plugins::objects::MAX_ITEMS - kept[0].1.len(); + assert!(ran > 1 && !kept[0].1.is_empty(), "{ran} runs gave their pieces"); + assert_eq!(tinies, ran * 400, "every run that fit gave all its pieces"); + assert!(paths.len() <= vectorcraft_plugins::objects::MAX_OBJECTS, "{} paths", paths.len()); +} + +/// A later painted effect runs on each of 256 fills a painted effect returned on an 8,000-anchor +/// path, although 256 copies of that path would pass the effects' budget, when what it returns +/// fits. +#[test] +fn a_later_painted_effect_runs_on_many_fills_of_a_large_piece_when_its_results_fit() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let zigzag: Vec = (0..8_000).map(|i| Point::new(40.0 + f64::from(i) * 20.0 / 8_000.0, if i % 2 == 0 { 40.0 } else { 60.0 })).collect(); + let large = PathData::single(SubPath::polyline(&zigzag, true)); + let fills = vec![solid("#ff0000"); vectorcraft_plugins::objects::MAX_ITEMS]; + let first = json!({"objects": [{"path": large, "fills": fills}]}).to_string(); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let small = json!({"objects": [{"path": tiny}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.large.fills", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.large.small", true), &small)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.large.fills", "plugin.test.engine.large.small"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let paths = painted_paths(&s, id); + let tinies = paths.iter().filter(|(b, _)| *b == tiny.bounds()).count(); + assert_eq!(tinies, vectorcraft_plugins::objects::MAX_ITEMS, "every fill's run"); + assert!(paths.iter().all(|(b, _)| *b != large.bounds()), "no fill left on the large path"); +} + +/// An open path of `curves` tall curves, each drawn with about 5,200 lines. +fn tall_curves(curves: u32) -> PathData { + let h = 3_000_000.0; + let anchors = (0..=curves) + .map(|i| { + let x = 40.0 + f64::from(i); + vectorcraft_geom::Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + PathData::single(SubPath::new(anchors, false)) +} + +/// A later painted effect's run on the first of two fills a painted effect returned on a path that +/// takes most of the effects' budget gives its result when it and the second fill's result fit +/// together, although the result and another copy of the path would not. +#[test] +fn a_later_painted_run_counts_the_results_after_it_before_keeping_room_for_its_input() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let (large, result) = (tall_curves(1_000), tall_curves(600)); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let two = json!({"objects": [{"path": large, "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let on_first = json!({"objects": [{"path": result}]}).to_string(); + let on_second = json!({"objects": [{"path": tiny}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.tall.fills", true), &two)); + let second = painting("test.engine.tall.each", true); + install_wat(&mut s, &tpl::when_given_else(&second, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_second)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.tall.fills", "plugin.test.engine.tall.each"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let want = [(result.bounds(), vec![(paint("#ff0000"), None)]), (tiny.bounds(), vec![(paint("#00ff00"), None)])]; + assert_eq!(painted_paths(&s, id), want, "both runs give their results"); +} + +/// When the results of a later painted effect's runs on the two fills of a large path do not fit +/// together, and the first fill's result and another copy of the path do not fit either, the first +/// fill stays on the path and the second fill's result, which fits beside the path, is drawn. +#[test] +fn a_later_painted_run_that_did_not_fit_beside_an_earlier_result_fits_once_that_is_left_out() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let (large, first, second) = (tall_curves(577), tall_curves(1_058), tall_curves(770)); + let two = json!({"objects": [{"path": large, "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let on_first = json!({"objects": [{"path": first}]}).to_string(); + let on_second = json!({"objects": [{"path": second}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.refit.fills", true), &two)); + let each = painting("test.engine.refit.each", true); + install_wat(&mut s, &tpl::when_given_else(&each, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_second)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.refit.fills", "plugin.test.engine.refit.each"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let want = [(large.bounds(), vec![(paint("#ff0000"), None)]), (second.bounds(), vec![(paint("#00ff00"), None)])]; + assert_eq!(painted_paths(&s, id), want, "the second fill's result is drawn"); } /// A Transform effect before a painted effect on a fill moves the fill's placed gradient: a piece @@ -1183,6 +1422,39 @@ fn the_first_view_defers_plug_in_effects_past_its_budget() { assert_eq!(fx_c.take_report(), Some(0)); } +/// A later painted effect deferred on the fills a piece returned is listed once, at its place among +/// the object's fill's effects, and a refresh of the object runs it on each of them. +#[test] +fn deferred_runs_on_the_fills_a_piece_returned_keep_the_fill_s_stage() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.two", true), &two)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.later", true), &blue)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.deferred.two", "item": fill})); + // The first effect's result is kept from a drawing outside a view's budget. + assert_eq!(drawn(&s).pixel(50, 50), GREEN); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.deferred.later", "item": fill})); + 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)); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (GREEN, WHITE), "the fills on the first effect's piece"); + 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::>(); + assert_eq!(places, [(id.0, Some(fill as u64), 1)], "the fill's stage, listed once"); + step.store(0, std::sync::atomic::Ordering::Relaxed); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": [id.0]})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (WHITE, BLUE), "both fills replaced"); +} + /// 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. diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0beabd34cb..936d5d9e53 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -127,11 +127,31 @@ const NEEDLE_AT: usize = 0x8000; /// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a /// test sees from the geometry whether the host handed the plug-in something. pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String { + given_or(manifest, given, needle, output, None) +} + +/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and +/// `otherwise` when it does not. +pub fn when_given_else(manifest: &str, given: Given, needle: &str, output: &str, otherwise: &str) -> String { + given_or(manifest, given, needle, output, Some(otherwise)) +} + +/// [`when_given`], returning `otherwise` in place of the input when it is given. +fn given_or(manifest: &str, given: Given, needle: &str, output: &str, otherwise: Option<&str>) -> String { let (src, len) = match given { Given::Input => ("$in", "$il"), Given::Params => ("$pp", "$pl"), }; let n = needle.len(); + // `otherwise` sits right after `output`. + let other_at = OUTPUT_AT + output.len(); + let (none, other) = match otherwise { + Some(o) => ( + format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {other_at}))", o.len()), + format!(r#" (data (i32.const {other_at}) "{}")"#, string(o)), + ), + None => (ECHO.to_string(), String::new()), + }; // For each place `$i` the needle fits, compare it byte by byte (`$j`). let run = format!( r#"(local $i i32) (local $j i32) @@ -149,12 +169,13 @@ pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> S (br $cmp))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $at))) - (return {ECHO})) + (return {none})) (i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#, out = output.len(), ); - let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), string(output)); - module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) + let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}"){other}"#, string(needle), string(output)); + let end = other_at + otherwise.map_or(0, str::len); + module(manifest, &run, &extra, end.div_ceil(0x1_0000) as u32 + 1) } /// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs). diff --git a/docs/plugins.md b/docs/plugins.md index a932983acd..5284dee98f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -779,6 +779,11 @@ is a piece drawn by itself, in the order returned, with paints of its own: starts at 1 pt, miter join, butt cap, miter limit 10. - An effect on a fill (`_context.item` `{"fill": i}`) returns only `fills`, which replace that fill on each piece; an effect on a stroke returns only `strokes`. The object's other fills and strokes stay where they are. +- Each fill or stroke such an effect returns keeps the effects after it in that fill's or stroke's stack. A later + live effect, and a later geometry effect on strokes, runs once on each of them in paint order: it is given the + piece's `fills` and `strokes` with `_context.item` naming that one (`{"fill": 1}` for the second fill on the + piece), and its result replaces only that fill or stroke. A run that fails, is refused, is deferred or would pass + the budget leaves its own fill or stroke as it was, and the others still run. - The effects after it in the stack apply to the pieces together, the reference box being the bounds of all of them. Bounds, raster effects, Pathfinder effects, compound shapes and Envelope Options › Distort Appearance use the pieces as they are drawn. Outside outline mode, an image is also hit where the pieces of its own fills and strokes paint; a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4ce26c5ee2..cfe388918f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1856,6 +1856,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **The warning of a preview applied when a command ends a drag:** a preview that Real-time Drawing off left unapplied, and that Undo, Clear History or another command applies before it runs, keeps its warning: the command's result carries it as `previewWarning`, and as `warning` when the command has none of its own, and the status bar shows it, as releasing the drag shows it. Typing kept as its own step when a tool's drag begins, or when Cmd lends the Selection tool, shows its applied preview's warning in the status bar too. Regressions: `settling_a_deferred_preview_keeps_its_warning` and `settling_typing_for_a_tool_shows_its_preview_warning` in `crates/engine/src/tests/prefs.rs`, `settled_warnings_join_the_result` in `crates/engine/src/session/execute.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Later effects on every fill or stroke a painted effect returns (G17):** when a painted effect on a fill or stroke returns several fills or strokes, a later live-effect plug-in in that fill's or stroke's stack, and a later geometry effect on strokes, runs once on each of them in paint order, given its own paint and its own `_context.item` among them. Each run's pieces replace only that fill or stroke. A run that fails, is refused, is deferred or would pass the effects' budget leaves only its own fill or stroke as it was, and the others still run; a deferred stage is listed once, at the source fill's or stroke's place. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/effects/src/tests/painted.rs`. Scores and hours unchanged. - **Painted pieces with brushed strokes are culled and measured by their brush art (G17):** a piece that keeps the object's brushed stroke draws the brush along the piece's own path at the piece's stroke width. Culling leaves VectorCraft's usual room for brush art (16 × the stroke width + 8 pt) past each piece, from the piece's width rather than the object's, and so does culling of a group, layer, symbol instance, blend, envelope or repeat that holds the object. A group or layer also leaves that room for each brushed path among its members, painted or not. Object › Rasterize takes the brush art drawn along each piece and leaves out the object's own brush art, which is not drawn. Pieces without a brush are culled as before. Regressions: `crates/render/src/tests/paintedbrush.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.rs`. Scores and hours unchanged.