Files
vectorcraft/crates/engine/src/cmd/effectcmd/tests.rs
T

1019 lines
59 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. A group holding a clipping group builds from the art it shows, 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: each effect builds from the art it shows.
for effect in effects::SOLIDS {
let (mut s, _, g) = group_with_a_clipping_group();
s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap_or_else(|e| panic!("{effect}: {e}"));
let n = node(&s, g);
assert_eq!(n.children().unwrap().len(), 2, "{effect}: the members stay as they were");
let art = effects::solid_art_in(Some(&s.doc().unwrap().doc), &n);
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{effect}: draws its solid");
}
// An image in the group: refused, nothing changed.
let (mut s, _, g) = group_with_an_image();
let before = s.doc().unwrap().doc.clone();
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("Image Trace"), "{err}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
}
/// Extrude & Bevel is refused, and nothing changes, on a path whose fill's and strokes' solids each
/// fit but together pass the limit of their faces; with one stroke the effect applies.
#[test]
fn extrude_is_refused_when_its_solids_together_are_too_complex() {
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem};
let (mut s, id) = session_with_rect();
let points: Vec<vectorcraft_geom::Point> = (0..16_384)
.map(|k| {
let (sin, cos) = (k as f64 * std::f64::consts::TAU / 16_384.0).sin_cos();
vectorcraft_geom::Point::new(20_000.0 + 10_000.0 * cos, 20_000.0 + 10_000.0 * sin)
})
.collect();
let polygon = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
let painted = |strokes: usize| {
let mut items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items;
let stroke = AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::WHITE), 20.0));
items.extend(std::iter::repeat_n(stroke, strokes));
items
};
let set = |s: &mut Session, strokes: usize| {
let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap();
let NodeKind::Path { path, live, .. } = &mut n.kind else { panic!("a path") };
*path = polygon.clone();
*live = None;
n.appearance.items = painted(strokes);
};
// The fill's solid and each stroke's fit on their own; sixty strokes' together do not.
set(&mut s, 60);
let before = s.doc().unwrap().doc.clone();
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("The 3D surface is too complex; simplify the shape"), "{err}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
set(&mut s, 1);
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
assert!(effects::solid_art(&node(&s, id)).is_some());
}
/// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session,
/// the clipping group and the group.
fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) {
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 = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
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.0, c]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
(s, clip, g)
}
/// A group of a rectangle and an image: the session, the image and the group.
fn group_with_an_image() -> (Session, NodeId, NodeId) {
let (mut s, a) = session_with_rect();
let image = s
.edit("Place", |d, _| {
let id = d.alloc_id();
let img = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 4,
height: 4,
xf: vectorcraft_geom::Affine::translate((250.0, 250.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let (parent, index, _) = d.position(a).unwrap();
d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?;
Ok(id)
})
.unwrap();
s.execute("select.set", &json!({"ids": [a.0, image.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
(s, image, g)
}
/// #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 an image is named. A clipping
/// group, alone or in a group, sweeps the art it shows.
#[test]
fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() {
let (mut s, image, g) = group_with_an_image();
let before = s.doc().unwrap().doc.clone();
for target in [image, g] {
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("Revolve") && err.contains("Image Trace"), "{err}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
}
for clipping_group_itself in [true, false] {
let (mut s, clip, g) = group_with_a_clipping_group();
let target = if clipping_group_itself { clip } else { g };
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null}))
.unwrap_or_else(|e| panic!("{clipping_group_itself}: {e}"));
let art = effects::revolve_art_in(Some(&s.doc().unwrap().doc), &node(&s, target));
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{clipping_group_itself}: draws its surface");
}
}
/// 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());
}
/// `effect.setParams` refuses a `visible` that is not true or false, when called and when a step
/// plays, and the effect, the document and the undo history stay as they were. `null` leaves the
/// visibility as it is.
#[test]
fn set_params_refuses_a_visible_that_is_not_true_or_false() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": null})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for visible in [json!(1), json!(0), json!("false"), json!([])] {
let p = json!({"index": 0, "item": null, "visible": visible, "params": {"size": 9}});
for replay in [false, true] {
let r = if replay { s.replay("effect.setParams", &p) } else { s.execute("effect.setParams", &p) };
let err = r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("`visible` must be true or false"), "{visible} (replay {replay}): {err}");
let state = s.doc().unwrap();
assert_eq!(state.doc, before, "{visible} (replay {replay})");
assert_eq!(state.history.undo.len(), undo, "{visible} (replay {replay})");
assert!(!state.is_dirty());
}
}
s.execute("effect.setParams", &json!({"index": 0, "item": null, "visible": null, "params": {"size": 9}})).unwrap();
let n = node(&s, id);
assert!(n.appearance.effects[0].visible);
assert_eq!(n.appearance.effects[0].params["size"], 9);
}
/// The effect `effect.setParams` checks the parameters against is the one it leaves: a Warp's new
/// style and a 3D effect's new type, and the effect as it is otherwise.
#[test]
fn set_params_checks_the_effect_it_leaves() {
use super::edited_effect_id;
assert_eq!(edited_effect_id("warp.arc", None, Some("flag")), "warp.flag");
assert_eq!(edited_effect_id("warp.arc", None, None), "warp.arc");
assert_eq!(edited_effect_id("distort.roughen", None, Some("flag")), "distort.roughen");
assert_eq!(edited_effect_id("threeD.extrude", Some("threeD.revolve"), None), "threeD.revolve");
assert_eq!(edited_effect_id("warp.arc", Some("threeD.revolve"), None), "warp.arc");
let (mut s, _) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
let p = json!({"index": 0, "item": null, "warpStyle": "flag", "params": {"bend": 25}});
assert_eq!(super::set_params_effects(&s, &p), ["warp.flag"]);
}
#[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);
}
/// A 3D effect's shadow is set with `effect.setParams` in one undo step, saved in the native file,
/// drawn in exports and kept as a path by Expand Appearance; settings out of range are refused
/// without an undo step.
#[test]
fn a_3d_shadow_is_one_undo_step_saved_exported_and_expanded() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for (params, why) in [
(json!({"shadowBounds": 401}), "shadowBounds must be a number from 10 to 400"),
(json!({"shadowPosition": "left"}), "shadowPosition must be behind or below"),
] {
let before = s.doc().unwrap().doc.clone();
let e = s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap_err().to_string();
assert!(e.contains(why), "{params}: {e}");
assert_eq!(s.doc().unwrap().doc, before);
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let params = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 20, "shadowBounds": 150});
s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let art = effects::solid_art(&node(&s, id)).unwrap();
let shadow = art.children().unwrap()[0].clone();
assert!(matches!(shadow.kind, NodeKind::Path { .. }));
// Saved and opened again, the effect keeps its shadow settings and draws the same shadow.
let doc = &s.doc().unwrap().doc;
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
let fx = &restored.node(id).unwrap().appearance.effects[0];
for (key, value) in params.as_object().unwrap() {
assert_eq!(fx.params.get(key), Some(value), "{key}");
}
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art);
// SVG export draws it: one more shape than without the shadow.
let shapes = |s: &mut Session| {
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());
vectorcraft_testkit::svg_shapes(&svg)
};
let with = shapes(&mut s);
s.execute("edit.undo", &json!({})).unwrap();
let without = shapes(&mut s);
assert_eq!(with, without + 1);
s.execute("edit.redo", &json!({})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
let mut shadows = 0;
s.doc().unwrap().doc.walk(|n| {
if n.path_data().is_some() && n.path_data() == shadow.path_data() && n.appearance.items == shadow.appearance.items {
shadows += 1;
}
});
assert_eq!(shadows, 1);
}