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
+1 -1
View File
@@ -82,7 +82,7 @@ pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
view_options,
validate_three_d, view_options,
};
pub use warp::{WarpStyle, warp_point};
+7
View File
@@ -143,6 +143,13 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
facets(id, &path, rule, b, p).map(|_| ())
}
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
/// [`validate_solid`].
pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
+8 -14
View File
@@ -139,7 +139,7 @@ fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
Ok(())
}
const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.apply";
@@ -172,13 +172,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if effects::is_three_d(id) {
for target in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
if id == effects::REVOLVE {
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
if effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
} else {
effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?;
effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?;
if id == effects::REVOLVE && effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
if effects::has_revolve(n) || effects::has_solid(n) {
return Err(bad(C, ONE_3D));
@@ -372,11 +368,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, &params)) {
cur.extend(new);
}
if e.id == effects::REVOLVE {
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
} else {
effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
}
effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
}
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
@@ -410,7 +402,9 @@ const GLASS: &str = "distort.glass";
/// Effect › Texture › Texturizer.
const TEXTURIZER: &str = "texture.texturizer";
fn texture_load_effect(id: &str) -> bool {
/// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image
/// file (Glass, Texturizer)?
pub(crate) fn texture_load_effect(id: &str) -> bool {
id == GLASS || id == TEXTURIZER
}
+46 -1
View File
@@ -11,6 +11,7 @@
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
use super::*;
@@ -85,13 +86,57 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let out = objects::decode(&plugin.run(&input, &params).map_err(err)?).map_err(err)?;
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?;
let ids = objects::apply_output(d, &inputs, out, parent, &admit_effects).map_err(err)?;
sel.set(ids.iter().copied());
Ok(ids)
})?;
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own
/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass,
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
/// host read one.
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
use vectorcraft_render::effects;
const C: &str = "plugin.run";
let mut out = Vec::with_capacity(fx.len());
for (j, e) in fx.iter().enumerate() {
if n.appearance.effects.contains(e) {
out.push(e.clone());
continue;
}
let refuse = |why: String| format!("[{j}] `{}`: {why}", e.id);
let params = if e.params.is_null() { json!({}) } else { e.params.clone() };
match effect::installed(&e.id) {
Some(plugin) => {
plugin.manifest().resolve_params(&params).map_err(|why| refuse(why.to_string()))?;
}
None => super::params::check_effect_params(C, &e.id, &params).map_err(|why| refuse(why.to_string()))?,
}
if super::effectcmd::texture_load_effect(&e.id)
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
&& !t.as_str().is_some_and(|t| effects::pixel::texture(t).is_some())
{
return Err(refuse("`textureImage` must be a texture as effect.list gives it".into()));
}
let mut admitted = effects::new_effect(&e.id, &params).ok_or_else(|| refuse("no such effect".into()))?;
if effects::is_three_d(&e.id) {
effects::validate_three_d(n, &e.id, &admitted.params).map_err(refuse)?;
}
admitted.visible = e.visible;
out.push(admitted);
}
let items = n.appearance.items.iter().flat_map(|i| i.effects());
if out.iter().chain(items).filter(|e| effects::is_three_d(&e.id)).count() > 1 {
return Err(super::effectcmd::ONE_3D.into());
}
Ok(out)
}
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
+68
View File
@@ -262,6 +262,74 @@ fn stroke_width_test_effect() {
std::fs::write(std::path::Path::new(&dir).join("stroke-width-test-effect.wasm"), wat::parse_str(src).unwrap()).unwrap();
}
/// A filter is given its objects' effect records and their strokes' join, cap and miter limit, and
/// may return them. The records are admitted as effect.apply would admit them; one that isn't
/// refuses the whole output, and nothing changes.
#[test]
fn filters_set_effects_and_stroke_styles() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(
&mut s,
&tpl::module(&tpl::manifest("test.engine.fx", "effect", r#"{"n":{"type":"int","min":0,"max":3,"default":1}}"#), tpl::ECHO, "", 1),
);
let black = json!({"type": "solid", "color": {"model": "gray", "k": 1.0}});
let out = |fx: Value| json!({"objects": [{"id": id.0, "strokes": [{"paint": black, "width": 4, "join": "round", "cap": "square", "miterLimit": 4}], "effects": fx}]});
let filter = |s: &mut Session, name: &str, fx: Value| {
install_wat(s, &tpl::returning(&tpl::manifest(name, "filter", "{}"), &out(fx).to_string()));
s.execute("plugin.run", &json!({"id": name, "ids": [id.0]}))
};
let fx = json!([{"id": "distort.roughen", "params": {"size": 3}}, {"id": "plugin.test.engine.fx", "params": {"n": 2}, "visible": false}]);
filter(&mut s, "test.engine.setfx", fx).unwrap();
let n = node(&s, id);
let st = n.appearance.stroke().unwrap();
assert_eq!((st.width, st.join, st.cap, st.miter_limit), (4.0, vectorcraft_doc::LineJoin::Round, vectorcraft_doc::LineCap::Square, 4.0));
let effects = &n.appearance.effects;
assert_eq!(effects.iter().map(|e| (e.id.as_str(), e.visible)).collect::<Vec<_>>(), [("distort.roughen", true), ("plugin.test.engine.fx", false)]);
assert_eq!(effects[0].params, vectorcraft_render::effects::merged_params("distort.roughen", &json!({"size": 3})), "filled in");
assert_eq!(effects[1].params, json!({"n": 2}));
// Handed back unchanged (an echo), nothing changes, not even the records' parameters.
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.same", "filter", "{}"), tpl::ECHO, "", 1));
let before = s.doc().unwrap().doc.clone();
run(&mut s, "plugin.run", json!({"id": "test.engine.same", "ids": [id.0]}));
assert_eq!(s.doc().unwrap().doc, before);
// Refused: what effect.apply refuses, a live effect that isn't installed, parameters its
// manifest refuses, a texture to read from a file, two 3D effects.
for (name, fx, why) in [
("test.engine.unknown", json!([{"id": "distort.nothing"}]), "no such effect"),
("test.engine.key", json!([{"id": "distort.roughen", "params": {"bogus": 1}}]), "bogus"),
("test.engine.gone", json!([{"id": "plugin.test.engine.gone"}]), "no such effect"),
("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"),
("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"),
] {
let e = filter(&mut s, name, fx).unwrap_err().to_string();
assert!(e.contains("objects[0].effects") && e.contains(why), "{name}: {e}");
assert_eq!(s.doc().unwrap().doc, before, "{name}");
}
}
/// `CRAFT_PLUGIN_FRAME_WASM=<folder>` saves a test filter to try in the app
/// (`rough-frame-test-filter.wasm`): it adds a frame around 60,40–340,260 with a 6 pt stroke with
/// round joins and caps, roughened by the Roughen effect.
#[test]
fn rough_frame_test_filter() {
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_FRAME_WASM") else { return };
let frame = PathData::single(SubPath::polyline(
&[Point::new(60.0, 40.0), Point::new(340.0, 40.0), Point::new(340.0, 260.0), Point::new(60.0, 260.0)],
true,
));
let ink = json!({"type": "solid", "color": {"model": "rgb", "r": 0.15, "g": 0.2, "b": 0.45}});
let out = json!({"objects": [{"name": "Frame", "path": frame, "fills": [],
"strokes": [{"paint": ink, "width": 6, "join": "round", "cap": "round"}],
"effects": [{"id": "distort.roughen", "params": {"size": 2, "detail": 8, "points": "smooth"}}]}]});
let manifest = r#"{"id":"org.example.frame","name":"Rough Frame (test plug-in)","version":"1","kind":"filter",
"description":"Adds a frame whose stroke is roughened by the Roughen effect.","params":{}}"#;
let src = tpl::returning(manifest, &out.to_string());
std::fs::write(std::path::Path::new(&dir).join("rough-frame-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap();
}
#[test]
fn plugin_management_commands() {
let mut s = session();
+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);
}
+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}");
}
}
+6 -1
View File
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -183,7 +183,9 @@ type and `blob` but without their data, which follows the JSON in a saved file;
"strokes": [{"paint": {"type": "gradient", "gradient": {"kind": "Linear", "stops": [
{"offset": 0, "color": {"model": "gray", "k": 0}},
{"offset": 1, "color": {"model": "cmyk", "c": 0, "m": 0.5, "y": 1, "k": 0}}]}, "angle": 0},
"width": 2}],
"width": 2, "join": "round", "cap": "butt", "miterLimit": 10}],
"effects": [{"id": "distort.roughen", "params": {"detail": 10, "points": "smooth", "relative": true, "seed": 0,
"size": 5}, "visible": true}],
"opacity": 1,
"bounds": [10, 0, 75, 60]}
]}
@@ -192,12 +194,16 @@ type and `blob` but without their data, which follows the JSON in a saved file;
- `type`: `"path"` or `"compound"` (a compound path: `path` holds its members' subpaths, painted as one).
- `path`: VectorCraft's own path form. Each subpath is a run of anchors; `p` is the anchor, `in` / `out` its handles
(absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down.
- `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. Paints use the
- `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. A stroke has its
`paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`)
and `miterLimit`. Paints use the
native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient":
{"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}]}, "geom"?, "angle",
"freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk",
c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except
Lab's.
- `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or
`plugin.<id>` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed.
- `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only).
### Parameters
@@ -226,17 +232,27 @@ For an **object filter**, the output is the new state of the input objects:
- An object with the `id` of an input object updates it; keys it leaves out keep their value (`{"id": 12}` keeps the
object as it is). `fills` and `strokes` set the object's fills and strokes in order: extra entries add fills (above
the last one) or strokes, missing ones are removed; each stroke keeps its weight unless `width` is given (a new one: 1 pt).
the last one) or strokes, missing ones are removed; each stroke keeps its weight, join, cap and miter limit unless
`width`, `join`, `cap` or `miterLimit` are given (a new one: 1 pt, miter join, butt cap, miter limit 10).
- `effects` replaces the object's own effects, in order: each `{id, params?, visible?}`. A record the object already had
stays as it is; a new one is admitted as `effect.apply` admits it: an effect `effect.list` lists or an installed
live-effect plug-in, with parameters it takes (filled in to their defaults; a plug-in's checked against its
manifest), at most one 3D effect (its fills' and strokes' own effects counted), which must fit the object, and a
Glass `textureImage` as Glass stores it (base64 of a PNG: a plug-in can't have the host read a file). A tool can
then set up objects for a live effect in one step.
- An object without an `id` is new and needs a `path`. It goes just above the object listed before it, or, listed
before any input object, just below the first one listed after it. New objects take a path's defaults: no fills or
strokes, full opacity.
- Input objects missing from the output are deleted. The order of the input objects themselves doesn't change.
- `type` turns a path into a compound path (one member per subpath) or back; `name: ""` clears the name.
- A `path` returned as it was keeps a live shape live (a rectangle keeps its corner widgets); a changed one makes it a
plain path.
The host validates everything before the document changes: coordinates must be finite and within ±4,000,000 pt, at
most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops; colours, opacities and offsets are
clamped into range, stroke weights to 0–1,000 pt; a pattern must exist in the document. A paint returned unchanged
keeps its swatch link; any other paint is unlinked.
clamped into range, stroke weights to 0–1,000 pt and miter limits to 1–500; a pattern must exist in the document. A
paint returned unchanged keeps its swatch link; any other paint is unlinked. One refused effect record refuses the
whole output.
For a **live effect**, the geometry of every returned object's `path` (their subpaths, in order) replaces the
object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry.
File diff suppressed because one or more lines are too long