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
+110 -11
View File
@@ -27,6 +27,11 @@ fn names(d: &Document, layer: NodeId) -> Vec<String> {
d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect()
}
/// Admits every effect record as it is (the host's checks are the engine's).
fn keep(fx: &[Effect], _: &Node) -> std::result::Result<Vec<Effect>, String> {
Ok(fx.to_vec())
}
fn decode_json(v: Value) -> Result<Vec<OutObject>> {
decode(v.to_string().as_bytes())
}
@@ -56,10 +61,35 @@ fn objects_round_trip_through_the_json_model() {
// Echoed back unchanged, nothing changes (swatch links included).
let before = d.clone();
let out = decode(&serde_json::to_vec(&input).unwrap()).unwrap();
assert_eq!(apply_output(&mut d, &ids, out, None).unwrap(), ids);
assert_eq!(apply_output(&mut d, &ids, out, None, &keep).unwrap(), ids);
assert_eq!(d, before);
}
/// A path handed back as it was stays the live shape it was (a rectangle keeps its corner
/// widgets); a path that changed is a plain path.
#[test]
fn an_unchanged_path_stays_a_live_shape() {
let (mut d, ids, _) = doc();
let a = ids[0];
let live = vectorcraft_doc::LiveShape::Rectangle {
w: 10.0,
h: 10.0,
radii: [0.0; 4],
radii_relative: false,
kinds: Default::default(),
xf: vectorcraft_geom::Affine::IDENTITY,
};
if let Some(NodeKind::Path { live: l, .. }) = d.node_mut(a).map(|n| &mut n.kind) {
*l = Some(Box::new(live));
}
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
let before = d.clone();
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap();
assert_eq!(d, before, "still a live rectangle");
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "path": square(1.0)}]})).unwrap(), None, &keep).unwrap();
assert!(matches!(&d.node(a).unwrap().kind, NodeKind::Path { live: None, .. }), "moved: a plain path");
}
#[test]
fn outputs_update_add_and_delete_in_place() {
let (mut d, ids, layer) = doc();
@@ -67,7 +97,7 @@ fn outputs_update_add_and_delete_in_place() {
let new = |name: &str| json!({"name": name, "path": square(5.0), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}}]});
// A recoloured, B dropped, C kept; new objects around them.
let out = json!({"objects": [new("n1"), {"id": a, "fills": [null, {"type": "solid", "color": {"model": "rgb", "r": 5, "g": 0, "b": 0}}]}, new("n2"), {"id": c}, new("n3")]});
let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None).unwrap();
let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap();
assert_eq!(names(&d, layer), ["n1", "A", "n2", "C", "n3", "?", "?"]);
assert_eq!(sel.len(), 5);
assert!(d.node(NodeId(b)).is_none());
@@ -83,11 +113,11 @@ fn outputs_update_add_and_delete_in_place() {
fn new_objects_take_the_place_of_replaced_inputs_or_go_on_top() {
let (mut d, ids, layer) = doc();
let out = json!({"objects": [{"name": "X", "path": square(0.0)}]});
apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None).unwrap();
apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None, &keep).unwrap();
assert_eq!(names(&d, layer), ["A", "X", "?", "?"], "B and C replaced by X at B's place");
// No inputs (a generator): on top of the given container.
let (mut d, _, layer) = doc();
apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer)).unwrap();
apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap();
assert_eq!(names(&d, layer).last().map(String::as_str), Some("X"));
}
@@ -98,7 +128,7 @@ fn geometry_type_and_strokes_change() {
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
let blue = json!({"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}});
let out = json!({"objects": [{"id": a.0, "type": "compound", "path": two, "fillRule": "evenodd", "strokes": [{"paint": blue, "width": 4000}], "opacity": 0.5, "name": ""}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap();
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
let n = d.node(a).unwrap();
let NodeKind::Compound { children, rule } = &n.kind else { panic!("not a compound: {:?}", n.kind) };
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd));
@@ -107,7 +137,7 @@ fn geometry_type_and_strokes_change() {
assert_eq!(geometry(n).unwrap().0, two);
// And back to a single path; strokes removed.
let out = json!({"objects": [{"id": a.0, "type": "path", "strokes": []}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap();
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
let n = d.node(a).unwrap();
assert!(matches!(&n.kind, NodeKind::Path { path, .. } if *path == two));
assert!(n.appearance.stroke().is_none());
@@ -119,15 +149,15 @@ fn changed_paints_lose_swatch_links_and_patterns_must_exist() {
let a = ids[0];
d.node_mut(a).unwrap().appearance.set_fill(Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 });
let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Red", "tint": 0.5});
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None).unwrap();
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None, &keep).unwrap();
assert!(matches!(d.node(a).unwrap().appearance.fill_paint(), Paint::Solid { swatch: Some(_), .. }), "unchanged: kept");
let other = json!({"type": "solid", "color": {"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}, "swatch": "Red", "tint": 0.5});
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None).unwrap();
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None, &keep).unwrap();
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
let pattern = json!({"objects": [{"id": a.0, "fills": [{"type": "pattern", "pattern": "Dots"}]}]});
assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None).is_err());
assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None, &keep).is_err());
d.patterns.push(serde_json::from_value(json!({"name": "Dots", "tile": {"x0": 0, "y0": 0, "x1": 10, "y1": 10}, "art": []})).unwrap());
assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None).is_ok());
assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None, &keep).is_ok());
}
#[test]
@@ -153,8 +183,77 @@ fn bad_outputs_are_refused() {
json!({"error": "nothing to do"}),
] {
let mut d = d.clone();
let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None));
let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None, &keep));
assert!(r.is_err(), "{bad}");
}
assert!(decode(b"\xff\xfe").is_err());
}
/// Strokes come with their join, cap and miter limit and objects with their effect records; both go
/// back the same way, the records only as the host admits them.
#[test]
fn stroke_styles_and_effect_records_round_trip() {
let (mut d, ids, _) = doc();
let a = ids[0];
let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 5.0}), visible: true };
d.node_mut(a).unwrap().appearance.effects.push(roughen.clone());
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
let (stroke, effects) = (&input["objects"][0]["strokes"][0], &input["objects"][0]["effects"]);
assert_eq!((&stroke["join"], &stroke["cap"], &stroke["miterLimit"]), (&json!("miter"), &json!("butt"), &json!(10.0)));
assert_eq!(*effects, json!([{"id": "distort.roughen", "params": {"size": 5.0}, "visible": true}]));
// Handed back unchanged, nothing changes; the host sees the records and what the object had.
let seen = std::cell::RefCell::new(vec![]);
let admit = |fx: &[Effect], n: &Node| -> std::result::Result<Vec<Effect>, String> {
seen.borrow_mut().push((fx.to_vec(), n.appearance.effects.clone()));
Ok(fx.to_vec())
};
let before = d.clone();
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &admit).unwrap();
assert_eq!(d, before);
assert_eq!(*seen.borrow(), [(vec![roughen.clone()], vec![roughen.clone()])]);
// A round join, a square cap, a miter limit past the range; the effects replaced.
let out = json!({"objects": [{"id": a.0, "strokes": [{"paint": null, "join": "round", "cap": "square", "miterLimit": 900}],
"effects": [{"id": "x.y", "params": {"k": 1}, "visible": false}, {"id": "z"}]}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &admit).unwrap();
let n = d.node(a).unwrap();
let st = n.appearance.stroke().unwrap();
assert_eq!((st.join, st.cap, st.miter_limit, st.width), (LineJoin::Round, LineCap::Square, *MITER_LIMIT.end(), 1.0), "the weight kept");
let z = Effect { id: "z".into(), params: Value::Null, visible: true };
assert_eq!(n.appearance.effects, [Effect { id: "x.y".into(), params: json!({"k": 1}), visible: false }, z]);
// A new object's stroke takes what it is given; the rest is a new stroke's default.
let out = json!({"objects": [{"path": square(0.0), "strokes": [{"paint": null, "cap": "round"}]}]});
let layer = d.layers[0].id;
let new = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &admit).unwrap();
let st = d.node(new[0]).unwrap().appearance.stroke().unwrap().clone();
assert_eq!((st.cap, st.join, st.width, st.miter_limit), (LineCap::Round, LineJoin::Miter, 1.0, 10.0));
// What the host refuses changes nothing.
let before = d.clone();
let refuse = |_: &[Effect], _: &Node| -> std::result::Result<Vec<Effect>, String> { Err("not an effect".into()) };
let out = json!({"objects": [{"id": a.0, "effects": [{"id": "x.y"}]}]});
let e = apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &refuse).unwrap_err();
assert_eq!((e.to_string().contains("objects[0].effects: not an effect"), &d), (true, &before), "{e}");
}
#[test]
fn bad_stroke_styles_and_effect_records_are_refused() {
let a = doc().1[0].0;
for bad in [json!({"join": "pointy"}), json!({"cap": 3}), json!({"miterLimit": "x"}), json!({"width": "wide"})] {
let mut stroke = json!({"paint": null});
stroke.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
assert!(decode_json(json!({"objects": [{"id": a, "strokes": [stroke]}]})).is_err(), "{bad}");
}
let long = "x".repeat(MAX_EFFECT_ID + 1);
for bad in [
json!(3),
json!([3]),
json!([{}]),
json!([{"id": ""}]),
json!([{"id": 5}]),
json!([{"id": long}]),
json!([{"id": "a\nb"}]),
json!([{"id": "a", "params": [1]}]),
json!([{"id": "a", "visible": "yes"}]),
] {
assert!(decode_json(json!({"objects": [{"id": a, "effects": bad}]})).is_err(), "{bad}");
}
}