Plug-ins: filters read and set effects and stroke styles (G17)

A filter could reshape and repaint objects, but not set the stroke's
join, cap or miter limit, nor the effects an object's appearance holds.
A tool that prepares objects for a live effect (a sheet of samples,
each with the effect at its own settings) had to leave the user to
apply the effect to each object by hand.

- Each input stroke has its join, cap and miterLimit beside its paint
  and width (live effects get the same, through the shared encoder),
  and each object its own effect records, {id, params, visible}.
- An output stroke may set them (miter limit clamped to 1-500), and an
  output object's "effects" replaces its own effects.
- The host admits each record as effect.apply does: an effect that
  effect.list lists or an installed live-effect plug-in, parameters it
  takes (filled in to their defaults; a plug-in's checked against its
  manifest), one 3D effect that fits the object (its fills' and
  strokes' own effects counted), and the texture of an effect with
  Load Texture (Glass, Texturizer) as the effect stores it, never a
  file to read. A record the object already had stays as
  it is, so a filter that hands its input back changes nothing. One
  refused record refuses the whole output and the document stays as
  it was.

The plug-ins crate checks the records' form; which effects exist is the
engine's to say (objects::AdmitEffects, engine plugin::admit_effects).
effects::validate_three_d is the one check of whether a 3D effect fits
an object, for effect.apply, effect.setParams and the filters.

A path handed back as it was now stays the live shape it was: write()
dropped a live rectangle's or ellipse's parameters whenever the output
held a path, changed or not, so a filter that returned its input
turned a live rectangle into a plain path.

objects/tests.rs checks the round trip, stroke styles on existing and
new strokes, the host's refusal, the bad forms refused, and a live
rectangle handed back unchanged (a plain path before the fix);
tests_plugins.rs runs filters that set a built-in and a plug-in effect,
an echo that changes nothing, and six refused outputs.
CRAFT_PLUGIN_FRAME_WASM=<folder> saves a test filter to try in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
This commit is contained in:
Louise Fox
2026-10-22 01:25:50 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent ff021918a6
commit 78105cc695
10 changed files with 414 additions and 84 deletions
+144 -50
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use crate::{Error, Result};
@@ -26,6 +26,10 @@ pub const MAX_ANCHORS: usize = 4_000_000;
pub const MAX_ITEMS: usize = 256;
/// Thickest stroke a plug-in may set, in points.
pub const MAX_STROKE_WIDTH: f64 = 1000.0;
/// The range of a stroke's miter limit (as the Stroke panel's).
pub const MITER_LIMIT: std::ops::RangeInclusive<f64> = 1.0..=500.0;
/// Longest effect id a plug-in may return, in bytes.
pub const MAX_EFFECT_ID: usize = 128;
/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the
/// defaults: a path, non-zero rule, no fills or strokes, full opacity).
@@ -43,14 +47,50 @@ pub struct OutObject {
pub fills: Option<Vec<Paint>>,
pub strokes: Option<Vec<OutStroke>>,
pub opacity: Option<f32>,
/// The object's effects, in order, replacing its own (not its fills' or strokes'). The host
/// admits them before they reach the document ([`AdmitEffects`]).
pub effects: Option<Vec<Effect>>,
}
/// One stroke of an [`OutObject`].
/// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's
/// default: 1 pt, butt cap, miter join, miter limit 10).
#[derive(Clone, Debug, PartialEq)]
pub struct OutStroke {
pub paint: Paint,
/// `None`: keep the stroke's weight (1 pt for a new stroke).
pub width: Option<f64>,
pub join: Option<LineJoin>,
pub cap: Option<LineCap>,
pub miter_limit: Option<f64>,
}
impl OutStroke {
/// A stroke of `paint` that keeps everything else.
pub fn paint(paint: Paint) -> Self {
Self { paint, width: None, join: None, cap: None, miter_limit: None }
}
}
/// How the host admits the effect records a filter returns for object `n` (written with the
/// output, its effects still its own): the records to give it, or why they are refused. The
/// plug-in crate checks their form; which effects exist and what parameters they take is the
/// host's to say.
pub type AdmitEffects<'a> = &'a dyn Fn(&[Effect], &Node) -> std::result::Result<Vec<Effect>, String>;
/// The names plug-ins use for line joins and caps, as SVG's.
fn join_name(j: LineJoin) -> &'static str {
match j {
LineJoin::Miter => "miter",
LineJoin::Round => "round",
LineJoin::Bevel => "bevel",
}
}
fn cap_name(c: LineCap) -> &'static str {
match c {
LineCap::Butt => "butt",
LineCap::Round => "round",
LineCap::Square => "square",
}
}
fn rule_name(r: FillRule) -> &'static str {
@@ -84,25 +124,23 @@ pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> {
}
/// 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.
/// its paint, a stroke `{paint, width, join, cap, miterLimit}`. 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();
let stroke = |s: &StrokeLayer| json!({"paint": paint_json(&s.paint), "width": s.width, "join": join_name(s.join), "cap": cap_name(s.cap), "miterLimit": s.miter_limit});
let strokes = ap.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(stroke(s)) } 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}`.
/// fillRule, fills, strokes, effects, 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()),
"fills": fills, "strokes": strokes, "effects": n.appearance.effects, "opacity": n.opacity, "bounds": rect_json(path.bounds()),
});
if let Some(name) = &n.name {
o["name"] = json!(name);
@@ -268,18 +306,10 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject>
out.fills = Some(fills.iter().enumerate().map(|(i, f)| decode_paint(f, &format!("{at}.fills[{i}]"))).collect::<Result<_>>()?);
}
if let Some(strokes) = list("strokes")? {
let mut v = Vec::with_capacity(strokes.len());
for (i, s) in strokes.iter().enumerate() {
let at = format!("{at}.strokes[{i}]");
let s = s.as_object().ok_or_else(|| out_err(&at, "a stroke must be {\"paint\", \"width\"}"))?;
let paint = decode_paint(s.get("paint").unwrap_or(&Value::Null), &at)?;
let width = match opt(s, "width") {
None => None,
Some(w) => Some(w.as_f64().ok_or_else(|| out_err(&at, "`width` must be a number"))?.clamp(0.0, MAX_STROKE_WIDTH)),
};
v.push(OutStroke { paint, width });
}
out.strokes = Some(v);
out.strokes = Some(strokes.iter().enumerate().map(|(i, s)| decode_stroke(s, &format!("{at}.strokes[{i}]"))).collect::<Result<_>>()?);
}
if let Some(effects) = list("effects")? {
out.effects = Some(effects.iter().enumerate().map(|(i, e)| decode_effect(e, &format!("{at}.effects[{i}]"))).collect::<Result<_>>()?);
}
if let Some(op) = opt(o, "opacity") {
out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32));
@@ -287,6 +317,58 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject>
Ok(out)
}
/// A stroke from plug-in output: `{paint, width?, join?, cap?, miterLimit?}`, its weight clamped
/// to 0–[`MAX_STROKE_WIDTH`] and its miter limit into [`MITER_LIMIT`].
fn decode_stroke(v: &Value, at: &str) -> Result<OutStroke> {
let s = v.as_object().ok_or_else(|| out_err(at, "a stroke must be {\"paint\", \"width\"?, \"join\"?, \"cap\"?, \"miterLimit\"?}"))?;
let number = |key: &str, range: std::ops::RangeInclusive<f64>| -> Result<Option<f64>> {
let Some(v) = opt(s, key) else { return Ok(None) };
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| out_err(at, format!("`{key}` must be a number")))?;
Ok(Some(x.clamp(*range.start(), *range.end())))
};
let join = match opt(s, "join").map(|j| j.as_str()) {
None => None,
Some(Some("miter")) => Some(LineJoin::Miter),
Some(Some("round")) => Some(LineJoin::Round),
Some(Some("bevel")) => Some(LineJoin::Bevel),
Some(_) => return Err(out_err(at, "`join` must be \"miter\", \"round\" or \"bevel\"")),
};
let cap = match opt(s, "cap").map(|c| c.as_str()) {
None => None,
Some(Some("butt")) => Some(LineCap::Butt),
Some(Some("round")) => Some(LineCap::Round),
Some(Some("square")) => Some(LineCap::Square),
Some(_) => return Err(out_err(at, "`cap` must be \"butt\", \"round\" or \"square\"")),
};
Ok(OutStroke {
paint: decode_paint(s.get("paint").unwrap_or(&Value::Null), at)?,
width: number("width", 0.0..=MAX_STROKE_WIDTH)?,
join,
cap,
miter_limit: number("miterLimit", MITER_LIMIT)?,
})
}
/// An effect record from plug-in output: `{id, params?, visible?}`, in form only (the host says
/// whether the effect exists and takes the parameters). `params` stays as given, so a record
/// handed back unchanged equals the object's own.
fn decode_effect(v: &Value, at: &str) -> Result<Effect> {
let e = v.as_object().ok_or_else(|| out_err(at, "an effect must be {\"id\", \"params\"?, \"visible\"?}"))?;
let id = e.get("id").and_then(Value::as_str).filter(|id| (1..=MAX_EFFECT_ID).contains(&id.len()) && !id.chars().any(char::is_control));
let id = id.ok_or_else(|| out_err(at, format!("`id` must be an effect id of 1-{MAX_EFFECT_ID} bytes")))?;
let params = match e.get("params") {
None => Value::Null,
Some(p @ (Value::Null | Value::Object(_))) => p.clone(),
Some(_) => return Err(out_err(at, "`params` must be an object")),
};
let visible = match e.get("visible") {
None | Some(Value::Null) => true,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(out_err(at, "`visible` must be true or false")),
};
Ok(Effect { id: id.to_string(), params, visible })
}
/// Parses a plug-in's output: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in
/// declined (the message reaches the user).
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
@@ -343,7 +425,7 @@ fn unlinked(mut p: Paint) -> Paint {
/// `p` as it may enter `doc`: unchanged from `was` (the paint in the same slot before), or
/// without swatch links; a pattern must exist in the document.
fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Paint> {
fn admit_paint(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Paint> {
if let Paint::Pattern { pattern, .. } = &p
&& doc.pattern(pattern).is_none()
{
@@ -352,30 +434,27 @@ fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Pain
Ok(if was == Some(&p) { p } else { unlinked(p) })
}
/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new`, in paint order: extra items are
/// removed (from the top), missing ones added above the last item of their kind (fills at the
/// bottom, strokes on top when the object has none).
fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option<f64>)>) {
/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new` (a fill keeps everything but its
/// paint), in paint order: extra items are removed (from the top), missing ones added above the
/// last item of their kind (fills at the bottom, strokes on top when the object has none).
fn set_items(ap: &mut Appearance, fill: bool, new: Vec<OutStroke>) {
let idx: Vec<usize> = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect();
for &i in idx.iter().skip(new.len()).rev() {
ap.remove_item(i);
}
let kept = new.len().min(idx.len());
let mut at = idx.get(..kept).and_then(<[usize]>::last).map_or(if fill { 0 } else { ap.items.len() }, |i| i + 1);
for (k, (paint, width)) in new.into_iter().enumerate() {
for (k, o) in new.into_iter().enumerate() {
match idx.get(k).and_then(|&i| ap.items.get_mut(i)) {
Some(AppearanceItem::Fill(f)) => f.paint = paint,
Some(AppearanceItem::Stroke(s)) => {
s.paint = paint;
if let Some(w) = width {
s.width = w;
}
}
Some(AppearanceItem::Fill(f)) => f.paint = o.paint,
Some(AppearanceItem::Stroke(s)) => style(s, o),
None => {
let item = if fill {
AppearanceItem::Fill(FillLayer::new(paint))
AppearanceItem::Fill(FillLayer::new(o.paint))
} else {
AppearanceItem::Stroke(StrokeLayer::new(paint, width.unwrap_or(1.0)))
let mut s = StrokeLayer::new(Paint::None, 1.0);
style(&mut s, o);
AppearanceItem::Stroke(s)
};
ap.insert_item(at, item);
at += 1;
@@ -384,6 +463,15 @@ fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option<f64>)>) {
}
}
/// Gives stroke `s` the paint of `o` and each of its weight, join, cap and miter limit it has.
fn style(s: &mut StrokeLayer, o: OutStroke) {
s.paint = o.paint;
s.width = o.width.unwrap_or(s.width);
s.join = o.join.unwrap_or(s.join);
s.cap = o.cap.unwrap_or(s.cap);
s.miter_limit = o.miter_limit.unwrap_or(s.miter_limit);
}
/// A compound path's members: one path per subpath of `path`, keeping the ids of `old` members.
fn members(doc: &mut Document, old: &[Arc<Node>], path: PathData) -> Vec<Arc<Node>> {
path.subpaths
@@ -396,14 +484,16 @@ fn members(doc: &mut Document, old: &[Arc<Node>], path: PathData) -> Vec<Arc<Nod
.collect()
}
/// Writes `o` into `n` (an input object, or a fresh node for a new one).
fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> {
/// Writes `o` into `n` (an input object, or a fresh node for a new one); its effect records once
/// `admit` lets them in.
fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str, admit: AdmitEffects) -> Result<()> {
let (cur_path, cur_rule, cur_compound) = geometry(n).unwrap_or((PathData::default(), FillRule::NonZero, false));
let compound = o.compound.unwrap_or(cur_compound);
let rule = o.rule.unwrap_or(cur_rule);
match (&mut n.kind, compound) {
(NodeKind::Path { path, rule: r, live, .. }, false) => {
if let Some(p) = o.path {
// A path handed back as it was stays the live shape it was.
if let Some(p) = o.path.filter(|p| p != path) {
*path = p;
*live = None;
}
@@ -421,16 +511,16 @@ fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()>
(_, true) => n.kind = NodeKind::Compound { children: members(doc, &[], o.path.unwrap_or(cur_path)), rule },
(_, false) => n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false },
}
for (fill, new) in [
(true, o.fills.map(|f| f.into_iter().map(|p| (p, None)).collect::<Vec<_>>())),
(false, o.strokes.map(|s| s.into_iter().map(|s| (s.paint, s.width)).collect())),
] {
for (fill, new) in [(true, o.fills.map(|f| f.into_iter().map(OutStroke::paint).collect::<Vec<_>>())), (false, o.strokes)] {
let Some(new) = new else { continue };
let was = paints(&n.appearance, fill);
let admitted = new
.into_iter()
.enumerate()
.map(|(i, (p, w))| Ok((admit(doc, p, was.get(i).copied(), &format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }))?, w)))
.map(|(i, s)| {
let at = format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" });
Ok(OutStroke { paint: admit_paint(doc, s.paint.clone(), was.get(i).copied(), &at)?, ..s })
})
.collect::<Result<Vec<_>>>()?;
set_items(&mut n.appearance, fill, admitted);
}
@@ -440,6 +530,9 @@ fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()>
if let Some(name) = o.name {
n.name = Some(name).filter(|s| !s.trim().is_empty());
}
if let Some(effects) = o.effects {
n.appearance.effects = admit(&effects, n).map_err(|why| out_err(&format!("{at}.effects"), why))?;
}
Ok(())
}
@@ -539,8 +632,9 @@ impl Plan {
/// without one are new and go just above the object listed before them, or, listed before any
/// input object, just below the first one listed after them (at the first input object's place
/// when the output keeps none; on top of `parent` when there were no inputs). Input objects missing
/// from the output are deleted. Returns the resulting objects in output order (to select).
pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, parent: Option<NodeId>) -> Result<Vec<NodeId>> {
/// from the output are deleted. An object's effect records replace its own once `admit` lets them
/// in. Returns the resulting objects in output order (to select).
pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, parent: Option<NodeId>, admit: AdmitEffects) -> Result<Vec<NodeId>> {
let wanted: HashSet<NodeId> = inputs.iter().copied().collect();
let mut originals: HashMap<NodeId, Node> = HashMap::new();
doc.walk(|n| {
@@ -561,7 +655,7 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" };
out_err(&at, format!("{} {why}", id.0))
})?;
write(doc, &mut n, o, &at)?;
write(doc, &mut n, o, &at, admit)?;
if !run.is_empty() {
let nodes = std::mem::take(&mut run);
match prev {
@@ -578,7 +672,7 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
return Err(out_err(&at, "a new object needs a `path`"));
}
let mut n = Node::path(doc.alloc_id(), PathData::default(), Appearance::default());
write(doc, &mut n, o, &at)?;
write(doc, &mut n, o, &at, admit)?;
result.push(n.id);
run.push(n);
}