Merge pull request #3174 from KaboomFox/p8-filter-effects

Plug-ins: filters read and set effects and stroke styles (G17)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-22 01:35:21 +07:00
commit e2d75c7ba6
10 files changed
+440 -84

No files matched your search

+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 {
+14 -14
View File
@@ -139,8 +139,11 @@ 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.";
/// `effect.apply`: adds effect `effect` (or `id`) with `params`, its defaults filled in, to the
/// targets' active item as one undo step. A 3D effect must fit each target, which takes only one;
/// a Pathfinder effect on loose objects groups them first. → `{ids, index, item}`.
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.apply";
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
@@ -172,13 +175,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));
@@ -333,6 +332,9 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
set_params(s, &options)
}
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked
/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`.
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -372,11 +374,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 +408,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
}
+50 -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::*;
@@ -58,6 +59,10 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
}
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
/// parameters and `_context`, and applies its output as one undo step ([`objects::apply_output`];
/// effect records as [`admit_effects`] admits them). → `{plugin, ids}`: what it made or changed,
/// which is then selected.
fn run(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
@@ -85,13 +90,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();
+148 -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,51 @@ 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 name plug-ins use for line join `j`, as SVG's `stroke-linejoin`.
fn join_name(j: LineJoin) -> &'static str {
match j {
LineJoin::Miter => "miter",
LineJoin::Round => "round",
LineJoin::Bevel => "bevel",
}
}
/// The name plug-ins use for line cap `c`, as SVG's `stroke-linecap`.
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 +125,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);
@@ -227,6 +266,9 @@ fn opt<'a>(o: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a Value
o.get(key).filter(|v| !v.is_null())
}
/// Object `v` of a plug-in's output (`at` names it in errors), its anchors added to `anchors` and
/// held to [`MAX_ANCHORS`]: each key it has checked, numbers clamped into range, as [`decode`] reads
/// them.
fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject> {
let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?;
let mut out = OutObject::default();
@@ -268,18 +310,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 +321,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 +429,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 +438,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 +467,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 +488,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 +515,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 +534,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 +636,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 +659,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 +676,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);
}
+122 -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())
}
@@ -41,6 +46,8 @@ fn targets_are_selected_paths_in_paint_order() {
assert_eq!(filter_targets(&d, &[layer]), [ids[0], ids[2]]);
}
/// Objects as plug-ins are given them (type, name, fill rule, fills, strokes, bounds); handed back
/// unchanged, they change nothing, swatch links included.
#[test]
fn objects_round_trip_through_the_json_model() {
let (mut d, ids, _) = doc();
@@ -56,10 +63,37 @@ 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");
}
/// An output updates the input objects it lists, deletes the ones it leaves out, and adds new
/// objects where it lists them.
#[test]
fn outputs_update_add_and_delete_in_place() {
let (mut d, ids, layer) = doc();
@@ -67,7 +101,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());
@@ -79,18 +113,22 @@ fn outputs_update_add_and_delete_in_place() {
assert!(n1.appearance.stroke().is_none());
}
/// New objects take the place of the input objects they replace, or go on top of the given
/// container when there were none.
#[test]
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"));
}
/// An output turns a path into a compound path and back and sets its fill rule, strokes (weights
/// clamped), opacity and name.
#[test]
fn geometry_type_and_strokes_change() {
let (mut d, ids, _) = doc();
@@ -98,7 +136,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,29 +145,32 @@ 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());
}
/// A paint handed back as it was keeps its swatch link and any other is unlinked; a pattern paint
/// must name a pattern the document has.
#[test]
fn changed_paints_lose_swatch_links_and_patterns_must_exist() {
let (mut d, ids, _) = doc();
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());
}
/// Output the host can't read as objects is refused, whatever is wrong with it.
#[test]
fn bad_outputs_are_refused() {
let (d, ids, _) = doc();
@@ -153,8 +194,78 @@ 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}");
}
/// Stroke styles and effect records in a form the host doesn't read are refused.
#[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
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors aLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #16Line truncated
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@@ -355,6 +355,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
regressions.
- **Done:** a live effect is given its object's fills and strokes, read only, as a filter is, and `_context.item`
says which fill or stroke it sits on; a change of paint runs it again. Engine and unit regressions.
- **Done:** a filter is given its objects' effect records and their strokes' join, cap and miter limit, and may
return both: the records are admitted as `effect.apply` admits them (one refused refuses the output), so a tool can
set up objects for a live effect in one step. A path a filter hands back unchanged stays a live shape. Engine and
unit regressions.
- **Estimate:** 20–45 h for the APIs; the ecosystem itself isn't agent work.
## G18 Generative AI features
@@ -380,6 +384,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| 2026-10-20 | minor | G1: for the selection tools, a press within the selection tolerance of a clipping path's outline (a clip group's, or a layer's clipping mask) hits the clipping path before the art it clips, so Direct Selection takes its segments and the Selection tool the clip group even where the art lies over the mask's edge; Select Behind lists the same; the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. No entry closed, estimates unchanged. |
| 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. |
| 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. |
| 2026-10-19 | minor | G17: filter plug-ins read and set their objects' effect records (admitted as `effect.apply` admits them) and strokes' joins, caps and miter limits; a path handed back unchanged stays a live shape; engine and unit regressions. No entry closed, estimates unchanged. |
| 2026-10-19 | minor | G17: live-effect plug-ins are given their object's fills and strokes, read only, and the item they sit on; engine and unit regressions. No entry closed, estimates unchanged. |
| 2026-10-19 | minor | G17: plug-in parameters with labels, units, help, groups and conditions (`visibleWhen`), `string` and `numberList` types, up to 256 per plug-in; manifest and kittest dialog regressions. No entry closed, estimates unchanged. |
| 2026-10-19 | minor | G17: exporter plug-ins, plug-in file formats that Export As and `document.export` write; engine, ABI and headless Export As regressions. No entry closed, estimates unchanged. |
+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.
+3 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps survivingLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellationLine truncated
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -906,6 +906,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged.
- **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests_plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged.
- **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests_plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.
@@ -931,6 +932,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect
| 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. |
| 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. |
| 2026-10-20 | minor | M3.57: CSS swatch libraries preserve fractional stop positions in linear and radial gradients; synthetic color-crate regression. Scores and hours unchanged. |
| 2026-10-19 | minor | Plug-ins: filters read and set effect records and stroke joins, caps and miter limits (G17); a path handed back unchanged stays a live shape; engine and unit regressions. Scores and hours unchanged. |
| 2026-10-19 | minor | Plug-ins: live effects are given their object's fills and strokes, read only, and the item they sit on (G17); engine and unit regressions. Scores and hours unchanged. |
| 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. |
| 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. |