mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
@@ -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};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms)) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
let out = objects::decode(&plugin.run(&input, ¶ms).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(¶ms).map_err(|why| refuse(why.to_string()))?;
|
||||
}
|
||||
None => super::params::check_effect_params(C, &e.id, ¶ms).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, ¶ms).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;
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+21
-5
@@ -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
Reference in New Issue
Block a user