Files
vectorcraft/crates/engine/src/cmd/effectcmd/tests.rs
T
dotCooCoo 55e6c0a5b1 Revolve: a hidden Revolve's profile is measured before the effects after it
effect.setParams checks a Revolve before the edit, so turning a hidden Revolve back on measured
its profile after every geometry effect on the object, those after the Revolve included, and could
refuse a profile that draws. The check now measures the profile after only the geometry effects
before the Revolve, hidden or shown, as drawing does. An object holds at most one Revolve, hidden
ones included, so the check finds the same one that draws.
2026-11-09 19:47:40 -08:00

837 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves,
/// exports, expands and undoes as one step.
#[test]
fn type_revolves_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": "O", "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.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap();
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
let art = effects::revolve_art(&n).expect("a surface of the glyphs");
let mut tinted = false;
art.walk(&mut |c| {
if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
tinted |= r > g && r > b;
}
});
assert!(tinted, "shaded from the characters' red");
let doc = &s.doc().unwrap().doc;
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save");
let meshes = effects::three_d_meshes(&n).unwrap();
assert_eq!(meshes.len(), 1, "one mesh of every contour of the type");
s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap();
assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text");
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
assert!(!effects::has_revolve(&node(&s, id)), "expanded");
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(node(&s, id).kind, NodeKind::Text(_)));
assert!(effects::has_revolve(&node(&s, id)));
// Extrude & Bevel on type expands the same way.
s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap();
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
assert!(!effects::has_solid(&node(&s, id)), "expanded");
}
/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an
/// effect. Extrude & Bevel gives the same reason for empty type.
#[test]
fn revolve_refuses_type_and_groups_without_a_shape() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
for text in ["", " "] {
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap();
let id = NodeId(t["id"].as_u64().unwrap());
for effect in ["threeD.revolve", "threeD.extrude"] {
let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}");
assert!(node(&s, id).appearance.effects.is_empty());
}
}
let (mut s, a) = session_with_rect();
let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
for member in [a.0, b] {
s.execute("select.set", &json!({"ids": [member]})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
}
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("group holding a shape"), "{err}");
assert!(node(&s, g).appearance.effects.is_empty());
}
/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well.
#[test]
fn revolve_on_a_fill_is_checked_in_that_fills_paint() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{AppearanceItem, FillLayer};
let (mut s, id) = session_with_rect();
// The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it.
let count = (256 << 20) / (4 * 100 * std::mem::size_of::<GradientStop>());
let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect();
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
s.edit("fills", |d, _| {
let n = d.node_mut(id).unwrap();
n.appearance.items =
vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))];
Ok(())
})
.unwrap();
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string();
assert!(err.contains("simplify the gradient"), "{err}");
assert!(node(&s, id).appearance.items[0].effects().is_empty());
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap();
let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string();
assert!(err.contains("simplify the gradient"), "{err}");
}
/// A hidden Revolve still counts as the object's one Revolve: a second one is refused, and the
/// hidden one can be shown again over the geometry effects after it.
#[test]
fn a_second_revolve_is_refused_over_a_hidden_one() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap();
s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": false})).unwrap();
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [id.0], "item": null})).unwrap();
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("one Revolve effect"), "{err}");
let ids: Vec<_> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect();
assert_eq!(ids, ["threeD.revolve", "distort.roughen"]);
s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": true})).unwrap();
assert!(node(&s, id).appearance.effects[0].visible);
}
/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps
/// each member's paint, follows an edited member, expands and undoes as one step.
#[test]
fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() {
let (mut s, a) = session_with_rect();
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap();
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": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were");
let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members");
// The axis is the left edge of both members together (x = 100), so the right member's far
// wall sweeps out to 200 pt from it on either side: x = 300 and x = -100.
let bounds = art.visual_bounds().unwrap();
assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}");
let (mut red, mut blue) = (false, false);
art.walk(&mut |c| {
if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
red |= r > b;
blue |= b > r;
}
});
assert!(red && blue, "each member keeps its own paint");
assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member");
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, "both members exported as vector faces");
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap();
assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface");
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
assert!(!effects::has_revolve(&node(&s, g)), "expanded");
s.execute("edit.undo", &json!({})).unwrap();
assert!(effects::has_revolve(&node(&s, g)));
}
/// A group holding what can't be swept is refused as a whole, and a clipping group is named.
#[test]
fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() {
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 err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string();
assert!(err.contains("path, compound path, type or group"), "{err}");
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.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}");
assert!(node(&s, g).appearance.effects.is_empty());
}
/// 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(), &params(&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!((&params(&s)["overlapVariation"], &params(&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![400.0, 250.0, 100.0], "the original on top");
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([400.0, 250.0, 100.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, 250.0), (x, 100.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);
}