use serde_json::json; use vectorcraft_doc::NodeKind; use vectorcraft_render::effects; use crate::{NodeId, Session}; fn session_with_rect() -> (Session, NodeId) { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); (s, NodeId(r["id"].as_u64().unwrap())) } fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { s.doc().unwrap().doc.node(id).cloned().unwrap() } #[test] fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { let mut s = Session::new(); s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); let source = node(&s, id).path_data().unwrap().clone(); s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); let before = effects_for_test(&s, id); assert_eq!(node(&s, id).path_data(), Some(&source)); let doc = &s.doc().unwrap().doc; let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); assert!(bytes.starts_with(b"%PDF")); assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); assert_ne!(effects_for_test(&s, id), before); assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); assert!(!effects::has_revolve(&node(&s, id))); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id).path_data(), Some(&source)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(effects_for_test(&s, id), before); } /// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it: /// a bad value is refused and the document is left as it was, and good values are stored. #[test] fn hiding_a_3d_effect_still_checks_its_new_values() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let rect = |s: &mut Session, x: u32| { NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap()) }; let classic = rect(&mut s, 20); s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap(); let extrude = rect(&mut s, 120); s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap(); let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap()); s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap(); for (id, params, why) in [ (classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"), (classic, json!({"surface": "glossy"}), "`surface` must be one of"), (classic, json!({"bevel": "wavy"}), "bevel must be none or one of"), (extrude, json!({"lights": []}), "lights must contain one to ten lights"), (extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"), (profile, json!({"edge": "top"}), "Revolve edge must be left or right"), ] { let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false}); let e = s.execute("effect.setParams", &p).unwrap_err().to_string(); assert!(e.contains(why), "{params}: {e}"); assert_eq!(s.doc().unwrap().doc, before, "{params}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}"); } // A hidden switch of 3D type checks the values given with it for the new type. let before = s.doc().unwrap().doc.clone(); let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false}); let e = s.execute("effect.setParams", &p).unwrap_err().to_string(); assert!(e.contains("lights must contain one to ten lights"), "{e}"); assert_eq!(s.doc().unwrap().doc, before); // Hiding alone checks nothing. s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap(); assert!(!node(&s, extrude).appearance.effects[0].visible); s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap(); let e = &node(&s, classic).appearance.effects[0]; assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false)); // A value the effect already holds is not checked again: a key it does not take, as a file // can carry, does not stop a hidden edit of another value. std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1); s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap(); assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0)); } /// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, /// exported, expanded and undone; one 3D effect per object. #[test] fn solid_effects_apply_save_edit_export_expand_and_undo() { for effect in effects::SOLIDS { let (mut s, id) = session_with_rect(); let source = node(&s, id).path_data().unwrap().clone(); s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap(); let art = effects::solid_art(&node(&s, id)).unwrap(); assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable"); let doc = &s.doc().unwrap().doc; let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same"); // One 3D effect per object, whichever kind. for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] { assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}"); } s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap(); assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art"); assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); assert!(vectorcraft_testkit::svg_shapes(&svg) >= 2, "{effect}: exported as vector faces"); s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}"); assert!(!effects::has_solid(&node(&s, id))); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle"); } } /// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis; /// the members stay editable, and a group holding what can't become a solid is refused. #[test] fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { for effect in effects::SOLIDS { let (mut s, a) = session_with_rect(); let b = s.execute("shape.ellipse", &json!({"x": 250, "y": 120, "width": 80, "height": 60})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap(); let art = effects::solid_art(&node(&s, g)).unwrap_or_else(|| panic!("{effect}")); assert_eq!(node(&s, g).children().unwrap().len(), 2, "{effect}: the members stay as they were"); // A member moved, the solid follows. s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); assert_ne!(effects::solid_art(&node(&s, g)).unwrap(), art, "{effect}"); let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "{effect}: both members exported as vector faces"); s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded"); } // A clipping group in the group: refused, nothing changed. let (mut s, a) = session_with_rect(); let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); assert!(err.contains("clipping groups"), "{err}"); assert!(node(&s, g).appearance.effects.is_empty()); } /// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays /// type: editing its text reshapes the solid. #[test] fn type_extrudes_as_its_glyphs_and_stays_editable() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "3D", "size": 72})).unwrap(); let id = NodeId(t["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap(); s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); let n = node(&s, id); assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); let art = effects::solid_art(&n).expect("a solid of the glyphs"); let mut faces = 0; let mut tinted = false; art.walk(&mut |c| { if c.path_data().is_some() { faces += 1; // Shaded from the characters' red: red stays the strongest channel. if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { tinted |= r > g && r > b; } } }); assert!(faces > 2 && tinted, "{faces} faces, tinted {tinted}"); s.execute("text.setText", &json!({"ids": [id.0], "text": "3DD"})).unwrap(); assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text"); } /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. #[test] fn an_earlier_perspective_strength_is_set_as_its_angle() { let (mut s, id) = session_with_rect(); s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap(); let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap(); let angle = angle_of(60.0); assert!((angle - 30.12).abs() < 0.01, "{angle}"); assert!(params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{}", params(&s)); s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap(); assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12); // A file of then: the strength stored; setting an angle replaces it. std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"depth": 50, "perspective": 60}); s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap(); assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120})); } /// Scripts from before Rotation and Height give the light by `lightAzimuth` and /// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation. #[test] fn an_earlier_light_direction_is_set_as_rotation_and_height() { let (mut s, id) = session_with_rect(); s.execute("effect.apply", &json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}})) .unwrap(); let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); // Straight from behind the object, level with it. assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s)); assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), ¶ms(&s)["lightBehind"]), (90.0, &json!(true))); std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"lightAzimuth": 90, "lightElevation": 0}); s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap(); assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false})); } /// Scripts from before Scribble's separate variations give one `variation`, which is stored as the /// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°. #[test] fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() { let (mut s, id) = session_with_rect(); let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}}); s.execute("effect.apply", &p).unwrap(); let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s)); assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2))); s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap(); assert_eq!(params(&s)["angle"].as_f64(), Some(160.0)); // A file of then stores its variation; setting one of the two keeps it for the other. std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3}); s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap(); assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1})); } fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { effects::revolve_art(&node(s, id)).unwrap() } #[test] fn smooth_revolve_commands_validate_save_expand_and_undo() { let mut s = Session::new(); s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap(); let initial = node(&s, id); let source = initial.path_data().unwrap().clone(); assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap()); let mut gradients = 0; effects_for_test(&s, id).walk(&mut |face| { gradients += face.appearance.items.iter().filter(|item| { matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) }).count(); }); assert!(gradients > 0, "commands use smooth vector shading by default"); s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap(); let smooth = node(&s, id); assert_eq!(smooth.path_data(), Some(&source)); assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap()); let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id))); s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); assert!(!effects::has_revolve(&node(&s, id))); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), smooth); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), initial); } #[test] fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() { let mut s = Session::new(); s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); let source = node(&s, id).path_data().unwrap().clone(); s.execute( "effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}), ) .unwrap(); let uncapped = node(&s, id); assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false)); let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); let open_count = count(&effects_for_test(&s, id)); s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap(); let capped = node(&s, id); let art = effects_for_test(&s, id); assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); assert_eq!(capped.path_data(), Some(&source)); assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err()); assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true)); assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); assert_eq!(count(&node(&s, id)), count(&art)); assert!(!effects::has_revolve(&node(&s, id))); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), capped); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), uncapped); } #[test] fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { let mut s = Session::new(); s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); let live = node(&s, id); let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); let visible = count(&node(&s, id)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), live); s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); assert!(count(&node(&s, id)) > visible); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), live); // The shared command still expands ordinary multiple-fill artwork as before. let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); let ordinary = NodeId(r["id"].as_u64().unwrap()); s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); assert!(node(&s, ordinary).appearance.effects.is_empty()); } #[test] fn apply_uses_catalog_defaults_and_undoes() { let (mut s, id) = session_with_rect(); let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap(); assert_eq!(r["index"], json!(0)); let n = node(&s, id); assert_eq!(n.appearance.effects.len(), 1); assert_eq!(n.appearance.effects[0].params["x"], json!(3)); assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0)); s.execute("edit.undo", &json!({})).unwrap(); assert!(node(&s, id).appearance.effects.is_empty()); assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err()); } #[test] fn warp_options_style_change_preserves_slot_and_visibility() { let (mut s, id) = session_with_rect(); for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] { s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap(); } s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap(); s.doc_mut().unwrap().mark_saved(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}})); assert!(result.is_ok(), "an applied warp can change style in place: {result:?}"); let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap(); let n = node(&s, id); let new = n.appearance.effects_at(Some(1)).unwrap(); assert_eq!(new.len(), 3); assert_eq!(new[0], old[0]); assert_eq!(new[2], old[2]); assert_eq!(new[1].id, "warp.flag"); assert_eq!(new[1].params["bend"], 25.0); assert!(!new[1].visible); assert!(n.appearance.effects.is_empty()); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); let after = s.doc().unwrap().doc.clone(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); assert!(!s.doc().unwrap().is_dirty()); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, after); } #[test] fn warp_options_non_warp_target_rejection_is_atomic() { let (mut s, first) = session_with_rect(); s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap(); s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap(); s.doc_mut().unwrap().mark_saved(); let before = s.doc().unwrap().doc.clone(); let selection = s.doc().unwrap().selection.clone(); let undo = s.doc().unwrap().history.undo.len(); assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err()); let state = s.doc().unwrap(); assert_eq!(state.doc, before); assert_eq!(state.selection, selection); assert_eq!(state.history.undo.len(), undo); assert!(!state.is_dirty()); } #[test] fn list_remove_and_set_params() { let (mut s, id) = session_with_rect(); s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap(); s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); let l = s.execute("effect.list", &json!({})).unwrap(); assert!(l["catalog"].as_array().unwrap().len() >= 34); assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2); s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap(); let e = &node(&s, id).appearance.effects[0]; assert_eq!(e.params["angle"], json!(45)); assert!(!e.visible); s.execute("effect.remove", &json!({"index": 0})).unwrap(); let n = node(&s, id); assert_eq!(n.appearance.effects.len(), 1); assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow"); assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); } #[test] fn expand_appearance_bakes_geometry() { let (mut s, id) = session_with_rect(); s.execute("paint.setStroke", &json!({"none": true})).unwrap(); s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap(); s.execute("effect.expandAppearance", &json!({})).unwrap(); // One fill: the path itself, reshaped. let n = node(&s, id); let b = n.geometric_bounds().unwrap(); assert!((b.width() - 120.0).abs() < 0.1, "{b:?}"); assert!(n.appearance.effects.is_empty()); assert!(matches!(n.kind, NodeKind::Path { live: None, .. })); // Nothing left to expand. assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); } /// Each copy is an object of its own (#5612). #[test] fn expand_transform_copies_makes_a_group_of_the_copies() { let (mut s, id) = session_with_rect(); s.execute("paint.setStroke", &json!({"none": true})).unwrap(); s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); s.execute("effect.expandAppearance", &json!({})).unwrap(); let n = node(&s, id); assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label()); assert_eq!(n.children().map(|c| c.len()), Some(2)); let b = n.geometric_bounds().unwrap(); assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); } #[test] fn pathfinder_effect_on_loose_objects_groups_them_first() { let (mut s, a) = session_with_rect(); let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); assert_eq!(r["grouped"], true); let g = NodeId(r["ids"][0].as_u64().unwrap()); let gn = node(&s, g); assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::>(), [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::(); 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 = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect(); assert_eq!(xs, vec![100.0, 250.0, 400.0]); for c in copies { assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label()); assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0))); assert!((c.geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9); } let mut ids: Vec = vec![]; n.walk(&mut |c| ids.push(c.id)); ids.sort(); ids.dedup(); assert_eq!(ids.len(), 4, "the group and each copy have ids of their own"); // Two Transform effects: three columns, each a group of the column's two copies. s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(*s.doc().unwrap().doc, *before); s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); s.execute("effect.setParams", &json!({"index":1,"params":{"moveV":150,"copies":1},"ids":[id.0],"item":null})).unwrap(); s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); let columns = node(&s, id).children().unwrap().to_vec(); assert_eq!(columns.len(), 3); for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) { let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label())); let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect(); assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]); assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. }))); } } /// The object's own effect ids, in order. fn object_effects(s: &Session, id: NodeId) -> Vec { 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); }