mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Merge pull request #3173 from KaboomFox/p6-effect-paint
Plug-ins: live effects read their object's fills and strokes (G17)
This commit is contained in:
@@ -56,7 +56,7 @@ mod tests;
|
||||
mod tests_scale;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{
|
||||
@@ -941,6 +941,12 @@ pub struct GeomContext<'a> {
|
||||
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
|
||||
/// way and keeps its result on every redraw.
|
||||
pub seed: u64,
|
||||
/// The object's appearance, which live-effect plug-ins are given read only (their fills and
|
||||
/// strokes).
|
||||
pub appearance: Option<&'a Appearance>,
|
||||
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
|
||||
/// the object's effects.
|
||||
pub item: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> GeomContext<'a> {
|
||||
@@ -950,15 +956,17 @@ impl<'a> GeomContext<'a> {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
|
||||
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 }
|
||||
}
|
||||
|
||||
/// The context of the effects on `item`, one of the same object's appearance items: a
|
||||
/// stroke's own effects outline that stroke.
|
||||
/// stroke's own effects outline that stroke, and a plug-in is told which item it is on.
|
||||
pub fn item(self, item: &'a AppearanceItem) -> Self {
|
||||
let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item)));
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
|
||||
AppearanceItem::Fill(_) => self,
|
||||
AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self },
|
||||
AppearanceItem::Fill(_) => Self { item: at, ..self },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -997,7 +1005,8 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
|
||||
@@ -290,7 +290,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
@@ -363,15 +363,16 @@ fn reshaped(l: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
|
||||
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
|
||||
/// as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
|
||||
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
|
||||
/// `path` as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
if !has_geometry(&f.effects) {
|
||||
if !has_geometry(item.effects()) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
}
|
||||
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
|
||||
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
|
||||
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
}
|
||||
@@ -432,7 +433,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
(!t.is_empty()).then_some((f, fl, t))
|
||||
});
|
||||
|
||||
@@ -174,6 +174,94 @@ fn live_effect_plugins_reshape_editable_art_and_expand() {
|
||||
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
|
||||
}
|
||||
|
||||
/// A live effect is given its object's fills and strokes, read only, and which of them it sits on;
|
||||
/// a change of paint runs it again.
|
||||
#[test]
|
||||
fn live_effects_are_given_their_object_s_paints() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"}));
|
||||
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 5}));
|
||||
// The red fill as the host writes paints: a JSON value, its keys sorted.
|
||||
let red = serde_json::to_value(&node(&s, id).appearance.fill().unwrap().paint).unwrap().to_string();
|
||||
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
|
||||
// Each draws the triangle while it is given what it looks for, else hands the geometry back.
|
||||
let effect = |id: &str, given, needle: &str| tpl::when_given(&tpl::manifest(id, "effect", "{}"), given, needle, &tri);
|
||||
install_wat(&mut s, &effect("test.engine.wide", tpl::Given::Input, r#""width":5.0"#));
|
||||
install_wat(&mut s, &effect("test.engine.red", tpl::Given::Input, &format!(r#""fills":[{red}]"#)));
|
||||
install_wat(&mut s, &effect("test.engine.second", tpl::Given::Params, r#""item":{"stroke":1}"#));
|
||||
let effected = |s: &Session, item: Option<usize>| {
|
||||
let n = node(s, id);
|
||||
let path = n.path_data().unwrap().clone();
|
||||
let ctx = vectorcraft_render::effects::GeomContext::of(&n);
|
||||
let (effects, ctx) = match item {
|
||||
Some(i) => match &n.appearance.items[i] {
|
||||
vectorcraft_doc::AppearanceItem::Stroke(st) => (st.effects.clone(), ctx.item(&n.appearance.items[i])),
|
||||
other => panic!("not a stroke: {other:?}"),
|
||||
},
|
||||
None => (n.appearance.effects.clone(), ctx),
|
||||
};
|
||||
vectorcraft_render::effects::apply_geometry_with(&effects, &path, path.bounds().unwrap(), &ctx)
|
||||
};
|
||||
let own = node(&s, id).path_data().unwrap().clone();
|
||||
let remove = |s: &mut Session| run(s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": null}));
|
||||
for effect in ["plugin.test.engine.wide", "plugin.test.engine.red"] {
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": null}));
|
||||
assert_eq!(effected(&s, None), triangle(), "{effect}: given the 5 pt stroke and the red fill");
|
||||
remove(&mut s);
|
||||
}
|
||||
// Run again once the stroke is thinner.
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.wide", "item": null}));
|
||||
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 3, "item": 1}));
|
||||
assert_eq!(effected(&s, None), own, "a 3 pt stroke");
|
||||
remove(&mut s);
|
||||
// On the second of two strokes, and not on the first.
|
||||
run(&mut s, "appearance.addStroke", json!({"ids": [id.0]}));
|
||||
let top = node(&s, id).appearance.items.len() - 1;
|
||||
for item in [1, top] {
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.second", "item": item}));
|
||||
}
|
||||
assert_eq!(effected(&s, Some(1)), own, "the first stroke");
|
||||
assert_eq!(effected(&s, Some(top)), triangle(), "the second stroke");
|
||||
}
|
||||
|
||||
/// Expand gives a plug-in effect on a fill the context drawing gives it (`_context.item`), so a
|
||||
/// gradient fill is expanded into the shape the canvas fills.
|
||||
#[test]
|
||||
fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
|
||||
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
|
||||
let on_fill = tpl::when_given(&tpl::manifest("test.engine.onfill", "effect", "{}"), tpl::Given::Params, r#""item":{"fill":0}"#, &tri);
|
||||
install_wat(&mut s, &on_fill);
|
||||
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.onfill", "item": fill}));
|
||||
// Drawn, the gradient fills the triangle: a point of it outside the rectangle is painted.
|
||||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let drawn = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts);
|
||||
assert_ne!(drawn.pixel(15, 1), [255, 255, 255, 255], "drawn as a triangle");
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, "object.expand", json!({"steps": 2}));
|
||||
// The gradient's strips sit in a clip group whose clipping path is the shape filled.
|
||||
let group = s.doc().unwrap().selection.objects[0];
|
||||
let clip = node(&s, group).children().unwrap()[0].clone();
|
||||
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
|
||||
}
|
||||
|
||||
/// `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.
|
||||
#[test]
|
||||
fn stroke_width_test_effect() {
|
||||
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_PAINTS_WASM") else { return };
|
||||
let triangle = PathData::single(SubPath::polyline(&[Point::new(100.0, 230.0), Point::new(300.0, 230.0), Point::new(200.0, 80.0)], true));
|
||||
let manifest = r#"{"id":"org.example.strokewidth","name":"Stroke Width (test plug-in)","version":"1","kind":"effect",
|
||||
"description":"Draws a triangle while the object's stroke is 5 pt wide.","params":{}}"#;
|
||||
let src = tpl::when_given(manifest, tpl::Given::Input, r#""width":5.0"#, &json!({"objects": [{"path": triangle}]}).to_string());
|
||||
std::fs::write(std::path::Path::new(&dir).join("stroke-width-test-effect.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_management_commands() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
//! Live-effect plug-ins: a plug-in of kind `effect` rewrites one object's geometry each time it is
|
||||
//! drawn, from its appearance stack (an `Effect` record with id `plugin.<plug-in id>`).
|
||||
//!
|
||||
//! The plug-in is given the geometry and, read only, the object's fills and strokes as a filter
|
||||
//! sees them ([`Paints`]): an effect can draw as wide as the stroke it sits on, or tell its fills
|
||||
//! apart, without asking for them again as parameters.
|
||||
//!
|
||||
//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by
|
||||
//! plug-in installation, parameters and input geometry: 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`].
|
||||
//! 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`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
|
||||
use crate::objects::{decode, output_geometry};
|
||||
use crate::objects::{decode, output_geometry, paint_lists};
|
||||
use crate::{Error, Kind, Limits, Result, registry};
|
||||
|
||||
/// The effect id prefix of live-effect plug-ins: `plugin.<plug-in id>`.
|
||||
@@ -40,10 +45,47 @@ pub fn installed(effect_id: &str) -> Option<std::sync::Arc<crate::Plugin>> {
|
||||
registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect)
|
||||
}
|
||||
|
||||
/// What a live effect is given besides its geometry: the object's appearance and where in it the
|
||||
/// effect sits.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Paints<'a> {
|
||||
/// The object's fills and strokes (`None`: an object without, given as empty lists).
|
||||
pub appearance: Option<&'a Appearance>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and
|
||||
/// `_context.item`.
|
||||
struct Given {
|
||||
fills: Vec<Value>,
|
||||
strokes: Vec<Value>,
|
||||
item: Value,
|
||||
}
|
||||
|
||||
impl Paints<'_> {
|
||||
/// What the plug-in is given: `item` is `"object"`, or `{"fill": i}` / `{"stroke": i}` with `i`
|
||||
/// counting the object's fills or strokes from the bottom.
|
||||
fn given(&self) -> Given {
|
||||
let Some(ap) = self.appearance else { return Given { fills: Vec::new(), strokes: Vec::new(), item: json!("object") } };
|
||||
let (fills, strokes) = paint_lists(ap);
|
||||
let at = self.item.and_then(|i| Some((ap.items.get(i)?, ap.items.get(..i)?)));
|
||||
let item = match at {
|
||||
Some((AppearanceItem::Fill(_), below)) => json!({"fill": below.iter().filter(|i| i.is_fill()).count()}),
|
||||
Some((AppearanceItem::Stroke(_), below)) => json!({"stroke": below.iter().filter(|i| !i.is_fill()).count()}),
|
||||
None => json!("object"),
|
||||
};
|
||||
Given { fills, strokes, item }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct Key {
|
||||
serial: u64,
|
||||
params: String,
|
||||
/// The paints and `_context.item`, as the plug-in is given them.
|
||||
paints: String,
|
||||
bounds: [u64; 4],
|
||||
rule: FillRule,
|
||||
path: PathData,
|
||||
@@ -52,7 +94,7 @@ struct Key {
|
||||
impl Key {
|
||||
fn hash64(&self) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(self.serial, &self.params, self.bounds, self.rule).hash(&mut h);
|
||||
(self.serial, &self.params, &self.paints, self.bounds, self.rule).hash(&mut h);
|
||||
for sp in &self.path.subpaths {
|
||||
sp.closed.hash(&mut h);
|
||||
for a in &sp.anchors {
|
||||
@@ -84,15 +126,17 @@ fn note(id: &str, r: &Result<PathData>) {
|
||||
};
|
||||
}
|
||||
|
||||
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`): the new geometry,
|
||||
/// or `None` when the plug-in isn't installed, isn't an effect, or fails (the caller then keeps
|
||||
/// the geometry).
|
||||
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule) -> Option<PathData> {
|
||||
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`), of an object painted
|
||||
/// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect, or
|
||||
/// fails (the caller then keeps the geometry).
|
||||
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<PathData> {
|
||||
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
|
||||
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
|
||||
let given = paints.given();
|
||||
let key = Key {
|
||||
serial: plugin.serial(),
|
||||
params: resolved.to_string(),
|
||||
paints: json!([given.fills, given.strokes, given.item]).to_string(),
|
||||
bounds: [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(f64::to_bits),
|
||||
rule,
|
||||
path: path.clone(),
|
||||
@@ -103,7 +147,7 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
|
||||
{
|
||||
return out.clone();
|
||||
}
|
||||
let r = run(&plugin, resolved, path, bounds, rule);
|
||||
let r = run(&plugin, resolved, path, bounds, rule, given);
|
||||
// A drawing that was cancelled (an export stopped half way) says nothing about the plug-in:
|
||||
// the next drawing runs it again.
|
||||
if matches!(r, Err(Error::Cancelled)) {
|
||||
@@ -120,16 +164,45 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
|
||||
out
|
||||
}
|
||||
|
||||
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule) -> Result<PathData> {
|
||||
/// One run of `plugin` on `path`, with the object's paints as `given`.
|
||||
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given) -> Result<PathData> {
|
||||
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
|
||||
params["_context"] = json!({"mode": "effect", "bounds": b});
|
||||
params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item});
|
||||
let rule = match rule {
|
||||
FillRule::NonZero => "nonzero",
|
||||
FillRule::EvenOdd => "evenodd",
|
||||
};
|
||||
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b}]});
|
||||
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
|
||||
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms));
|
||||
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() };
|
||||
Ok(output_geometry(decode(&plugin.run_limited(&input, ¶ms, &limits)?)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{FillLayer, StrokeLayer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Fills and strokes come in paint order, and the item an effect sits on is counted among the
|
||||
/// fills or among the strokes.
|
||||
#[test]
|
||||
fn the_paints_and_the_item_given() {
|
||||
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 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)]);
|
||||
assert_eq!(g.fills[0], serde_json::to_value(Paint::solid(Color::WHITE)).unwrap());
|
||||
assert_eq!(given(Some(3)).item, json!({"stroke": 1}));
|
||||
assert_eq!(given(Some(2)).item, json!({"fill": 1}));
|
||||
assert_eq!(given(Some(0)).item, json!({"fill": 0}));
|
||||
assert_eq!(given(Some(9)).item, json!("object"), "no such item");
|
||||
let none = Paints::default().given();
|
||||
assert_eq!((none.fills, none.strokes, none.item), (vec![], vec![], json!("object")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,18 +83,23 @@ pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path,
|
||||
/// fillRule, fills, strokes, opacity, bounds}`.
|
||||
pub fn encode(n: &Node) -> Option<Value> {
|
||||
let (path, rule, compound) = geometry(n)?;
|
||||
let fills: Vec<Value> =
|
||||
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect();
|
||||
let strokes: Vec<Value> = n
|
||||
.appearance
|
||||
/// The fills and strokes of appearance `ap` as plug-ins see them, each in paint order: a fill is
|
||||
/// its paint, a stroke `{paint, width}`. Filters and live effects are given the same.
|
||||
pub(crate) fn paint_lists(ap: &Appearance) -> (Vec<Value>, Vec<Value>) {
|
||||
let fills = ap.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect();
|
||||
let strokes = ap
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(json!({"paint": paint_json(&s.paint), "width": s.width})) } else { None })
|
||||
.collect();
|
||||
(fills, strokes)
|
||||
}
|
||||
|
||||
/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path,
|
||||
/// fillRule, fills, strokes, opacity, bounds}`.
|
||||
pub fn encode(n: &Node) -> Option<Value> {
|
||||
let (path, rule, compound) = geometry(n)?;
|
||||
let (fills, strokes) = paint_lists(&n.appearance);
|
||||
let mut o = json!({
|
||||
"id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule),
|
||||
"fills": fills, "strokes": strokes, "opacity": n.opacity, "bounds": rect_json(path.bounds()),
|
||||
|
||||
@@ -41,6 +41,52 @@ pub fn returning(manifest: &str, output: &str) -> String {
|
||||
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
|
||||
}
|
||||
|
||||
/// What [`when_given`] looks through.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Given {
|
||||
/// The input JSON (the objects).
|
||||
Input,
|
||||
/// The parameters JSON, `_context` included.
|
||||
Params,
|
||||
}
|
||||
|
||||
/// Where [`when_given`] keeps the text it looks for.
|
||||
const NEEDLE_AT: usize = 0x8000;
|
||||
|
||||
/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and
|
||||
/// 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 {
|
||||
let (src, len) = match given {
|
||||
Given::Input => ("$in", "$il"),
|
||||
Given::Params => ("$pp", "$pl"),
|
||||
};
|
||||
let n = needle.len();
|
||||
// For each place `$i` the needle fits, compare it byte by byte (`$j`).
|
||||
let run = format!(
|
||||
r#"(local $i i32) (local $j i32)
|
||||
(block $found
|
||||
(block $none
|
||||
(loop $at
|
||||
(br_if $none (i32.gt_u (i32.add (local.get $i) (i32.const {n})) (local.get {len})))
|
||||
(local.set $j (i32.const 0))
|
||||
(block $next
|
||||
(loop $cmp
|
||||
(br_if $found (i32.eq (local.get $j) (i32.const {n})))
|
||||
(br_if $next (i32.ne (i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (local.get $j))))
|
||||
(i32.load8_u (i32.add (i32.const {NEEDLE_AT}) (local.get $j)))))
|
||||
(local.set $j (i32.add (local.get $j) (i32.const 1)))
|
||||
(br $cmp)))
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
(br $at)))
|
||||
(return {ECHO}))
|
||||
(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)
|
||||
}
|
||||
|
||||
/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs).
|
||||
pub fn manifest(id: &str, kind: &str, params: &str) -> String {
|
||||
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#)
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
+10
-4
@@ -161,7 +161,9 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet
|
||||
order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide and clipping
|
||||
paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never
|
||||
touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one
|
||||
object, holding only geometry (`type`, `path`, `fillRule`, `bounds`).
|
||||
object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts
|
||||
from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a
|
||||
fill's colour to decide its shape, but it changes only the geometry.
|
||||
|
||||
An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it
|
||||
but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object,
|
||||
@@ -209,7 +211,9 @@ type and `blob` but without their data, which follows the JSON in a saved file;
|
||||
|
||||
`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference
|
||||
box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard
|
||||
under the selection's centre, else the first. An export's `_context` is `{"mode": "export", "extension": "plt",
|
||||
under the selection's centre, else the first. A live effect's `item` says where it sits: `"object"` (one of the
|
||||
object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke,
|
||||
counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt",
|
||||
"name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the
|
||||
extension (the document's name when the export is not given a path), which some formats keep in their header.
|
||||
|
||||
@@ -257,13 +261,15 @@ The crate is not a workspace member. Any language that targets core WebAssembly
|
||||
WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never
|
||||
touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests
|
||||
build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a
|
||||
base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with.
|
||||
base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and
|
||||
`wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a
|
||||
live effect's geometry.
|
||||
|
||||
## Performance
|
||||
|
||||
The interpreter costs roughly 10–100× native speed per instruction. Filters handle a selection in one call, so a
|
||||
plug-in that parses and writes JSON with `serde_json` takes milliseconds for hundreds of paths. Live effects run once
|
||||
per change of their input (results are cached), not per frame.
|
||||
per change of their input, parameters or paints (results are cached), not per frame.
|
||||
|
||||
## Not in v1
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user