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:
BFlat
2026-10-20 08:49:13 +07:00
committed by GitHub
9 changed files with 274 additions and 41 deletions
+15 -6
View File
@@ -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),
+9 -8
View File
@@ -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))
});
+88
View File
@@ -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();
+86 -13
View File
@@ -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(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
Ok(output_geometry(decode(&plugin.run_limited(&input, &params, &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")));
}
}
+13 -8
View File
@@ -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()),
+46
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+10 -4
View File
@@ -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