mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 00:12:24 +00:00
effect.setParams with visible: false skipped every 3D parameter check, so a hidden edit could store a Classic depth of 2001, an unknown surface or bevel, an empty light list or a Revolve edge other than left or right. The values given are now checked by validate_three_d_params (the checks of validate_three_d that do not build the art); after a change of 3D type the values the effect keeps are checked too. The object still does not need to fit while the effect is hidden.
674 lines
40 KiB
Rust
674 lines
40 KiB
Rust
use serde_json::json;
|
||
use vectorcraft_doc::NodeKind;
|
||
use vectorcraft_render::effects;
|
||
|
||
use crate::{NodeId, Session};
|
||
|
||
fn session_with_rect() -> (Session, NodeId) {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||
let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap();
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
let source = node(&s, id).path_data().unwrap().clone();
|
||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap();
|
||
let before = effects_for_test(&s, id);
|
||
assert_eq!(node(&s, id).path_data(), Some(&source));
|
||
let doc = &s.doc().unwrap().doc;
|
||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||
assert_eq!(restored.node(id).unwrap().path_data(), Some(&source));
|
||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before);
|
||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||
assert!(bytes.starts_with(b"%PDF"));
|
||
assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err());
|
||
assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err());
|
||
s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap();
|
||
assert_ne!(effects_for_test(&s, id), before);
|
||
assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err());
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }));
|
||
assert!(!effects::has_revolve(&node(&s, id)));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id).path_data(), Some(&source));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(effects_for_test(&s, id), before);
|
||
}
|
||
|
||
/// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it:
|
||
/// a bad value is refused and the document is left as it was, and good values are stored.
|
||
#[test]
|
||
fn hiding_a_3d_effect_still_checks_its_new_values() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||
let rect = |s: &mut Session, x: u32| {
|
||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap())
|
||
};
|
||
let classic = rect(&mut s, 20);
|
||
s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap();
|
||
let extrude = rect(&mut s, 120);
|
||
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap();
|
||
let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap();
|
||
for (id, params, why) in [
|
||
(classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"),
|
||
(classic, json!({"surface": "glossy"}), "`surface` must be one of"),
|
||
(classic, json!({"bevel": "wavy"}), "bevel must be none or one of"),
|
||
(extrude, json!({"lights": []}), "lights must contain one to ten lights"),
|
||
(extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"),
|
||
(profile, json!({"edge": "top"}), "Revolve edge must be left or right"),
|
||
] {
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false});
|
||
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
|
||
assert!(e.contains(why), "{params}: {e}");
|
||
assert_eq!(s.doc().unwrap().doc, before, "{params}");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}");
|
||
}
|
||
// A hidden switch of 3D type checks the values given with it for the new type.
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false});
|
||
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
|
||
assert!(e.contains("lights must contain one to ten lights"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
// Hiding alone checks nothing.
|
||
s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap();
|
||
assert!(!node(&s, extrude).appearance.effects[0].visible);
|
||
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap();
|
||
let e = &node(&s, classic).appearance.effects[0];
|
||
assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false));
|
||
// A value the effect already holds is not checked again: a key it does not take, as a file
|
||
// can carry, does not stop a hidden edit of another value.
|
||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1);
|
||
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap();
|
||
assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0));
|
||
}
|
||
|
||
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
|
||
/// exported, expanded and undone; one 3D effect per object.
|
||
#[test]
|
||
fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||
for effect in effects::SOLIDS {
|
||
let (mut s, id) = session_with_rect();
|
||
let source = node(&s, id).path_data().unwrap().clone();
|
||
s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap();
|
||
let art = effects::solid_art(&node(&s, id)).unwrap();
|
||
assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable");
|
||
let doc = &s.doc().unwrap().doc;
|
||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same");
|
||
// One 3D effect per object, whichever kind.
|
||
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
|
||
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
|
||
}
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap();
|
||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
|
||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
|
||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
|
||
assert!(vectorcraft_testkit::svg_shapes(&svg) >= 2, "{effect}: exported as vector faces");
|
||
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
|
||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}");
|
||
assert!(!effects::has_solid(&node(&s, id)));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle");
|
||
}
|
||
}
|
||
|
||
/// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis;
|
||
/// the members stay editable, and a group holding what can't become a solid is refused.
|
||
#[test]
|
||
fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
|
||
for effect in effects::SOLIDS {
|
||
let (mut s, a) = session_with_rect();
|
||
let b = s.execute("shape.ellipse", &json!({"x": 250, "y": 120, "width": 80, "height": 60})).unwrap()["id"].clone();
|
||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap();
|
||
let art = effects::solid_art(&node(&s, g)).unwrap_or_else(|| panic!("{effect}"));
|
||
assert_eq!(node(&s, g).children().unwrap().len(), 2, "{effect}: the members stay as they were");
|
||
// A member moved, the solid follows.
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap();
|
||
assert_ne!(effects::solid_art(&node(&s, g)).unwrap(), art, "{effect}");
|
||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
|
||
assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "{effect}: both members exported as vector faces");
|
||
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
|
||
assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded");
|
||
}
|
||
// A clipping group in the group: refused, nothing changed.
|
||
let (mut s, a) = session_with_rect();
|
||
let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||
let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
|
||
let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
s.execute("select.set", &json!({"ids": [clip, c]})).unwrap();
|
||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||
assert!(err.contains("clipping groups"), "{err}");
|
||
assert!(node(&s, g).appearance.effects.is_empty());
|
||
}
|
||
|
||
/// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays
|
||
/// type: editing its text reshapes the solid.
|
||
#[test]
|
||
fn type_extrudes_as_its_glyphs_and_stays_editable() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "3D", "size": 72})).unwrap();
|
||
let id = NodeId(t["id"].as_u64().unwrap());
|
||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap();
|
||
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
|
||
let art = effects::solid_art(&n).expect("a solid of the glyphs");
|
||
let mut faces = 0;
|
||
let mut tinted = false;
|
||
art.walk(&mut |c| {
|
||
if c.path_data().is_some() {
|
||
faces += 1;
|
||
// Shaded from the characters' red: red stays the strongest channel.
|
||
if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
|
||
tinted |= r > g && r > b;
|
||
}
|
||
}
|
||
});
|
||
assert!(faces > 2 && tinted, "{faces} faces, tinted {tinted}");
|
||
s.execute("text.setText", &json!({"ids": [id.0], "text": "3DD"})).unwrap();
|
||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text");
|
||
}
|
||
|
||
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
|
||
/// stored as the angle that shows the same view, and a later angle replaces an earlier strength.
|
||
#[test]
|
||
fn an_earlier_perspective_strength_is_set_as_its_angle() {
|
||
let (mut s, id) = session_with_rect();
|
||
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap();
|
||
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
|
||
let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap();
|
||
let angle = angle_of(60.0);
|
||
assert!((angle - 30.12).abs() < 0.01, "{angle}");
|
||
assert!(params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{}", params(&s));
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap();
|
||
assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12);
|
||
// A file of then: the strength stored; setting an angle replaces it.
|
||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params =
|
||
json!({"depth": 50, "perspective": 60});
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap();
|
||
assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120}));
|
||
}
|
||
|
||
/// Scripts from before Rotation and Height give the light by `lightAzimuth` and
|
||
/// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation.
|
||
#[test]
|
||
fn an_earlier_light_direction_is_set_as_rotation_and_height() {
|
||
let (mut s, id) = session_with_rect();
|
||
s.execute("effect.apply", &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}))
|
||
.unwrap();
|
||
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
|
||
// Straight from behind the object, level with it.
|
||
assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s));
|
||
assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), ¶ms(&s)["lightBehind"]), (90.0, &json!(true)));
|
||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params =
|
||
json!({"lightAzimuth": 90, "lightElevation": 0});
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap();
|
||
assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false}));
|
||
}
|
||
|
||
/// Scripts from before Scribble's separate variations give one `variation`, which is stored as the
|
||
/// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°.
|
||
#[test]
|
||
fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() {
|
||
let (mut s, id) = session_with_rect();
|
||
let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}});
|
||
s.execute("effect.apply", &p).unwrap();
|
||
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
|
||
assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s));
|
||
assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2)));
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap();
|
||
assert_eq!(params(&s)["angle"].as_f64(), Some(160.0));
|
||
// A file of then stores its variation; setting one of the two keeps it for the other.
|
||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3});
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap();
|
||
assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1}));
|
||
}
|
||
|
||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||
effects::revolve_art(&node(s, id)).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn smooth_revolve_commands_validate_save_expand_and_undo() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||
let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap();
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap();
|
||
let initial = node(&s, id);
|
||
let source = initial.path_data().unwrap().clone();
|
||
assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||
assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap());
|
||
let mut gradients = 0;
|
||
effects_for_test(&s, id).walk(&mut |face| {
|
||
gradients += face.appearance.items.iter().filter(|item| {
|
||
matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_)))
|
||
}).count();
|
||
});
|
||
assert!(gradients > 0, "commands use smooth vector shading by default");
|
||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap();
|
||
let smooth = node(&s, id);
|
||
assert_eq!(smooth.path_data(), Some(&source));
|
||
assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap());
|
||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id)));
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
assert!(!effects::has_revolve(&node(&s, id)));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id), smooth);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id), initial);
|
||
}
|
||
|
||
#[test]
|
||
fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||
let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap();
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
let source = node(&s, id).path_data().unwrap().clone();
|
||
s.execute(
|
||
"effect.apply",
|
||
&json!({"effect":"threeD.revolve","ids":[id.0],"item":null,
|
||
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}),
|
||
)
|
||
.unwrap();
|
||
let uncapped = node(&s, id);
|
||
assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false));
|
||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||
let open_count = count(&effects_for_test(&s, id));
|
||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap();
|
||
let capped = node(&s, id);
|
||
let art = effects_for_test(&s, id);
|
||
assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap");
|
||
assert_eq!(capped.path_data(), Some(&source));
|
||
assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err());
|
||
assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document");
|
||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true));
|
||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone()));
|
||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||
assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF"));
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
assert_eq!(count(&node(&s, id)), count(&art));
|
||
assert!(!effects::has_revolve(&node(&s, id)));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id), capped);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id), uncapped);
|
||
}
|
||
|
||
#[test]
|
||
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||
let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap();
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap();
|
||
let live = node(&s, id);
|
||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true));
|
||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
let visible = count(&node(&s, id));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id), live);
|
||
s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap();
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
assert!(count(&node(&s, id)) > visible);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id), live);
|
||
// The shared command still expands ordinary multiple-fill artwork as before.
|
||
let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap();
|
||
let ordinary = NodeId(r["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap();
|
||
s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap();
|
||
assert!(node(&s, ordinary).appearance.effects.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn apply_uses_catalog_defaults_and_undoes() {
|
||
let (mut s, id) = session_with_rect();
|
||
let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap();
|
||
assert_eq!(r["index"], json!(0));
|
||
let n = node(&s, id);
|
||
assert_eq!(n.appearance.effects.len(), 1);
|
||
assert_eq!(n.appearance.effects[0].params["x"], json!(3));
|
||
assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(node(&s, id).appearance.effects.is_empty());
|
||
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn warp_options_style_change_preserves_slot_and_visibility() {
|
||
let (mut s, id) = session_with_rect();
|
||
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
|
||
s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap();
|
||
}
|
||
s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap();
|
||
s.doc_mut().unwrap().mark_saved();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}}));
|
||
assert!(result.is_ok(), "an applied warp can change style in place: {result:?}");
|
||
let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap();
|
||
let n = node(&s, id);
|
||
let new = n.appearance.effects_at(Some(1)).unwrap();
|
||
assert_eq!(new.len(), 3);
|
||
assert_eq!(new[0], old[0]);
|
||
assert_eq!(new[2], old[2]);
|
||
assert_eq!(new[1].id, "warp.flag");
|
||
assert_eq!(new[1].params["bend"], 25.0);
|
||
assert!(!new[1].visible);
|
||
assert!(n.appearance.effects.is_empty());
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||
let after = s.doc().unwrap().doc.clone();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert!(!s.doc().unwrap().is_dirty());
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc, after);
|
||
}
|
||
|
||
#[test]
|
||
fn warp_options_non_warp_target_rejection_is_atomic() {
|
||
let (mut s, first) = session_with_rect();
|
||
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
|
||
let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap();
|
||
s.doc_mut().unwrap().mark_saved();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let selection = s.doc().unwrap().selection.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err());
|
||
let state = s.doc().unwrap();
|
||
assert_eq!(state.doc, before);
|
||
assert_eq!(state.selection, selection);
|
||
assert_eq!(state.history.undo.len(), undo);
|
||
assert!(!state.is_dirty());
|
||
}
|
||
|
||
#[test]
|
||
fn list_remove_and_set_params() {
|
||
let (mut s, id) = session_with_rect();
|
||
s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap();
|
||
s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap();
|
||
let l = s.execute("effect.list", &json!({})).unwrap();
|
||
assert!(l["catalog"].as_array().unwrap().len() >= 34);
|
||
assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2);
|
||
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap();
|
||
let e = &node(&s, id).appearance.effects[0];
|
||
assert_eq!(e.params["angle"], json!(45));
|
||
assert!(!e.visible);
|
||
s.execute("effect.remove", &json!({"index": 0})).unwrap();
|
||
let n = node(&s, id);
|
||
assert_eq!(n.appearance.effects.len(), 1);
|
||
assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow");
|
||
assert!(s.execute("effect.remove", &json!({"index": 5})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn expand_appearance_bakes_geometry() {
|
||
let (mut s, id) = session_with_rect();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap();
|
||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||
// One fill: the path itself, reshaped.
|
||
let n = node(&s, id);
|
||
let b = n.geometric_bounds().unwrap();
|
||
assert!((b.width() - 120.0).abs() < 0.1, "{b:?}");
|
||
assert!(n.appearance.effects.is_empty());
|
||
assert!(matches!(n.kind, NodeKind::Path { live: None, .. }));
|
||
// Nothing left to expand.
|
||
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
|
||
}
|
||
|
||
/// Each copy is an object of its own (#5612).
|
||
#[test]
|
||
fn expand_transform_copies_makes_a_group_of_the_copies() {
|
||
let (mut s, id) = session_with_rect();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap();
|
||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
|
||
assert_eq!(n.children().map(|c| c.len()), Some(2));
|
||
let b = n.geometric_bounds().unwrap();
|
||
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn pathfinder_effect_on_loose_objects_groups_them_first() {
|
||
let (mut s, a) = session_with_rect();
|
||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||
assert_eq!(r["grouped"], true);
|
||
let g = NodeId(r["ids"][0].as_u64().unwrap());
|
||
let gn = node(&s, g);
|
||
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
|
||
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
|
||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||
// Live: one united shape, so expanding leaves a single path.
|
||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||
assert_eq!(node(&s, g).children().unwrap().len(), 1);
|
||
// Grouping and applying are one undo step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.node(g).is_none());
|
||
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
|
||
// A selection holding a group applies to each object as before; so does a single object.
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||
assert!(r.get("grouped").is_none());
|
||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() {
|
||
let (mut s, a) = session_with_rect();
|
||
let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap());
|
||
let glyphs = effects::outline_text(&node(&s, t)).unwrap();
|
||
let b = glyphs.geometric_bounds().unwrap();
|
||
assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}");
|
||
let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::<f64>();
|
||
let glyph_area = area(&glyphs);
|
||
s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap();
|
||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap();
|
||
// Live: the renderer leaves the glyph's place in the square empty.
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||
let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count();
|
||
assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}");
|
||
// Expanded: the group holds the square minus the glyph.
|
||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||
let left = area(&node(&s, g));
|
||
assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}");
|
||
}
|
||
|
||
/// A Pathfinder effect on a group renders live and expands to its result.
|
||
#[test]
|
||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||
let (mut s, a) = session_with_rect();
|
||
let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [a.0, b["id"]]})).unwrap();
|
||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||
s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap();
|
||
// Live: the renderer shows only the back square minus the front one.
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||
assert!(img.pixel(125, 150)[3] > 0);
|
||
assert_eq!(img.pixel(200, 150)[3], 0);
|
||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||
let n = node(&s, g);
|
||
assert!(n.appearance.effects.is_empty());
|
||
let ch = n.children().unwrap();
|
||
assert_eq!(ch.len(), 1);
|
||
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
|
||
}
|
||
/// Expand Appearance on a Transform effect with copies gives each copy its own gradient.
|
||
#[test]
|
||
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
|
||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||
let (mut s, id) = session_with_rect();
|
||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||
s.edit("gradient", |d, _| {
|
||
let n = d.node_mut(id).unwrap();
|
||
n.appearance.set_fill(ramp);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
let mut boxes = vec![];
|
||
node(&s, id).walk(&mut |n| {
|
||
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
|
||
boxes.push(n.geometric_bounds().unwrap().width());
|
||
}
|
||
});
|
||
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
|
||
}
|
||
|
||
/// Expand Appearance on a solid-filled path whose Transform effect makes copies gives a group of
|
||
/// one path per copy, each with the path's fill, not one compound path (#5612); a second Transform
|
||
/// effect's copies of those copies nest, a group per copy of the first.
|
||
#[test]
|
||
fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
|
||
use vectorcraft_color::{Color, Paint};
|
||
let fill = |n: &vectorcraft_doc::Node| n.appearance.fill_paint().color();
|
||
let (mut s, id) = session_with_rect();
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Group { clip: false, .. }), "{}", n.kind_label());
|
||
assert!(n.appearance.effects.is_empty());
|
||
let copies = n.children().unwrap();
|
||
assert_eq!(copies.len(), 3, "the path and its two copies");
|
||
let xs: Vec<f64> = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect();
|
||
assert_eq!(xs, vec![100.0, 250.0, 400.0]);
|
||
for c in copies {
|
||
assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label());
|
||
assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||
assert!((c.geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
|
||
}
|
||
let mut ids: Vec<NodeId> = vec![];
|
||
n.walk(&mut |c| ids.push(c.id));
|
||
ids.sort();
|
||
ids.dedup();
|
||
assert_eq!(ids.len(), 4, "the group and each copy have ids of their own");
|
||
// Two Transform effects: three columns, each a group of the column's two copies.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(*s.doc().unwrap().doc, *before);
|
||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||
s.execute("effect.setParams", &json!({"index":1,"params":{"moveV":150,"copies":1},"ids":[id.0],"item":null})).unwrap();
|
||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||
let columns = node(&s, id).children().unwrap().to_vec();
|
||
assert_eq!(columns.len(), 3);
|
||
for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) {
|
||
let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label()));
|
||
let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect();
|
||
assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]);
|
||
assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. })));
|
||
}
|
||
}
|
||
|
||
/// The object's own effect ids, in order.
|
||
fn object_effects(s: &Session, id: NodeId) -> Vec<String> {
|
||
node(s, id).appearance.effects.iter().map(|e| e.id.clone()).collect()
|
||
}
|
||
|
||
/// A rectangle with Drop Shadow, Outer Glow and Twist on the object.
|
||
fn three_effects() -> (Session, NodeId) {
|
||
let (mut s, id) = session_with_rect();
|
||
for e in ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"] {
|
||
s.execute("effect.apply", &json!({"effect": e, "ids": [id.0], "item": null})).unwrap();
|
||
}
|
||
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"]);
|
||
(s, id)
|
||
}
|
||
|
||
/// `effect.remove` with `indices` removes several effects in one undo step.
|
||
#[test]
|
||
fn remove_takes_several_effects_in_one_undo_step() {
|
||
let (mut s, id) = three_effects();
|
||
s.execute("effect.remove", &json!({"indices": [2, 0], "ids": [id.0], "item": null})).unwrap();
|
||
assert_eq!(object_effects(&s, id), ["stylize.outerGlow"]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"]);
|
||
// An index past the list fails and changes nothing; so does an empty list.
|
||
for bad in [json!([0, 3]), json!([]), json!(["x"])] {
|
||
assert!(s.execute("effect.remove", &json!({"indices": bad, "ids": [id.0], "item": null})).is_err(), "{bad}");
|
||
assert_eq!(object_effects(&s, id).len(), 3, "{bad}");
|
||
}
|
||
}
|
||
|
||
/// `effect.duplicate` with `indices` puts each copy right after its original.
|
||
#[test]
|
||
fn duplicate_takes_several_effects_each_copy_after_its_original() {
|
||
let (mut s, id) = three_effects();
|
||
s.execute("effect.duplicate", &json!({"indices": [0, 2], "ids": [id.0], "item": null})).unwrap();
|
||
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.dropShadow", "stylize.outerGlow", "distort.twist", "distort.twist"]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(object_effects(&s, id).len(), 3);
|
||
assert!(s.execute("effect.duplicate", &json!({"indices": [1, 5], "ids": [id.0], "item": null})).is_err());
|
||
assert_eq!(object_effects(&s, id).len(), 3);
|
||
}
|
||
|
||
/// `effect.move` with several `from` lands them together, in their order.
|
||
#[test]
|
||
fn move_takes_several_effects_that_land_together_in_order() {
|
||
// [Shadow, Glow, Twist]: Shadow and Twist moved to the end of the list land as one block.
|
||
let (mut s, id) = three_effects();
|
||
let r = s.execute("effect.move", &json!({"from": [0, 2], "to": 1, "ids": [id.0], "fromItem": null})).unwrap();
|
||
assert_eq!(object_effects(&s, id), ["stylize.outerGlow", "stylize.dropShadow", "distort.twist"]);
|
||
assert_eq!(r["index"], json!(1), "where the first of them landed");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"]);
|
||
// Into the stroke's list (item 1), copying: the object keeps its effects.
|
||
s.execute("effect.move", &json!({"from": [1, 2], "to": 0, "fromItem": null, "toItem": 1, "copy": true, "ids": [id.0]})).unwrap();
|
||
let n = node(&s, id);
|
||
let on_stroke: Vec<_> = n.appearance.items[1].effects().iter().map(|e| e.id.clone()).collect();
|
||
assert_eq!(on_stroke, ["stylize.outerGlow", "distort.twist"]);
|
||
assert_eq!(object_effects(&s, id).len(), 3);
|
||
// A bad index fails and changes nothing.
|
||
let before = node(&s, id).appearance;
|
||
assert!(s.execute("effect.move", &json!({"from": [0, 9], "to": 0, "fromItem": null, "ids": [id.0]})).is_err());
|
||
assert_eq!(node(&s, id).appearance, before);
|
||
}
|