M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options

Make with Warp and Make with Mesh open dedicated dialogs (style dropdown,
orientation, bend and distortion sliders; rows and columns) with live
preview; while an envelope is selected the menu shows Reset with Warp /
Reset with Mesh instead, which switch the envelope's kind and keep its
content (Maintain Envelope Shape samples the current surface). Envelope
Options is a full dialog prefilled from the selection (Anti-Alias,
Preserve Shape, Fidelity, Distort Appearance, Distort Linear Gradients,
Distort Pattern Fills); with nothing selected it sets the defaults for new
envelopes. object.envelope.info reads the settings. Release returns the
warp or mesh shape as a gradient mesh and keeps opacity, blend mode and
mask; Expand keeps name, knockout, isolation and mask; Object > Expand
expands envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 10:11:10 +07:00
co-authored by Claude Opus 5.5
parent 6994f248b2
commit fe6d6640fb
12 changed files with 1050 additions and 79 deletions
+211
View File
@@ -0,0 +1,211 @@
//! Envelope Distort: Reset with Warp / Mesh, Envelope Options, Release, Expand and Object → Expand,
//! driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::live::{self, EnvelopeKind, EnvelopeOptions, PreserveShape};
use vectorcraft_doc::{Knockout, Node, NodeKind, OpacityMask};
use vectorcraft_geom::Rect;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
/// A 200×100 rectangle at (100, 100) enveloped by `cmd` with `params` → (rectangle, envelope).
fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
let a = rect(s, 100.0, 100.0, 200.0, 100.0);
sel(s, &[a]);
(a, id_of(&s.execute(cmd, &params).unwrap()))
}
fn kind(s: &Session, id: NodeId) -> EnvelopeKind {
match node(s, id).kind {
NodeKind::Envelope { kind, .. } => kind,
k => panic!("{k:?}"),
}
}
fn options(s: &Session, id: NodeId) -> (EnvelopeOptions, f64) {
match node(s, id).kind {
NodeKind::Envelope { options, fidelity, .. } => (options, fidelity),
k => panic!("{k:?}"),
}
}
#[test]
fn new_envelopes_take_the_reference_defaults_and_options_change_them() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({}));
assert_eq!(options(&s, e), (EnvelopeOptions::NEW, 50.0));
s.execute(
"object.envelope.options",
&json!({"antiAlias": false, "preserveShape": "transparency", "distortAppearance": true, "distortLinearGradients": true, "distortPatternFills": true, "fidelity": 75}),
)
.unwrap();
let (o, f) = options(&s, e);
assert_eq!((o.anti_alias, o.preserve_shape, o.gradients(), o.patterns(), f), (false, PreserveShape::Transparency, true, true, 75.0));
let info = s.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!(info["id"], json!(e.0));
assert_eq!((info["type"].clone(), info["style"].clone(), info["preserveShape"].clone()), (json!("warp"), json!("arc"), json!("transparency")));
assert!(s.execute("object.envelope.options", &json!({"preserveShape": "alpha"})).is_err());
assert!(s.execute("object.envelope.options", &json!({"fidelity": -1})).is_err());
}
#[test]
fn options_with_nothing_selected_are_the_defaults_for_new_envelopes() {
let mut s = session();
s.execute("object.envelope.options", &json!({"distortAppearance": false, "fidelity": 20})).unwrap();
let info = s.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!((info["id"].clone(), info["distortAppearance"].clone(), info["fidelity"].clone()), (Value::Null, json!(false), json!(20.0)));
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3}));
let (o, f) = options(&s, e);
assert_eq!((o.distort_appearance, f), (false, 20.0));
assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 2, cols: 3, .. }));
}
#[test]
fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2}));
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 60, "orientation": "vertical"})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { ref style, bend, horizontal: false, .. } if style == "arch" && bend == 60.0));
assert_eq!(node(&s, e).children().unwrap()[0].id, a);
// Unset values keep the current warp's.
s.execute("object.envelope.resetWithWarp", &json!({"bend": -20})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { ref style, bend, horizontal: false, .. } if style == "arch" && bend == -20.0));
assert!(s.execute("object.envelope.resetWithWarp", &json!({"style": "nope"})).is_err());
// Back to a mesh that keeps the arch's shape: the top middle point is where the warp put it.
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 50, "horizontal": true})).unwrap();
let top = live::envelope_surface(&node(&s, e).children().unwrap().clone(), &kind(&s, e), 2, 2, vectorcraft_color::Color::BLACK).unwrap();
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
let EnvelopeKind::Mesh { rows: 2, cols: 2, points } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
assert!(points[1].distance(top.points[1].p) < 1e-9 && points[1].y < 99.0, "{:?}", points[1]);
// Without Maintain Envelope Shape: a flat grid over the content.
s.execute("object.envelope.resetWithMesh", &json!({"rows": 1, "cols": 1, "maintainShape": false})).unwrap();
assert_eq!(kind(&s, e), EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1) });
assert!(s.execute("object.envelope.resetWithMesh", &json!({"rows": 0})).is_err());
// One undo step per reset.
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 2, cols: 2, .. }));
// Nothing to reset without an envelope.
sel(&mut s, &[]);
assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err());
}
#[test]
fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap();
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
s.edit("mask", |d, _| {
d.node_mut(e).unwrap().mask = Some(Box::new(OpacityMask::new(mask, true)));
Ok(())
})
.unwrap();
let r = s.execute("object.envelope.release", &json!({})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], a);
let content = node(&s, a);
assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
// The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
let b = m.outline().bounds().unwrap();
assert!(b.y1 > 201.0, "{b:?}");
assert_eq!(s.doc().unwrap().selection.objects, ids);
}
#[test]
fn release_groups_several_objects_under_the_transparency() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0);
sel(&mut s, &[a, b]);
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 2})).unwrap());
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let r = s.execute("object.envelope.release", &json!({})).unwrap();
let g = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert_eq!(g.opacity, 0.5);
assert_eq!(g.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), vec![a, b]);
assert!(matches!(node(&s, NodeId(r["ids"][1].as_u64().unwrap())).kind, NodeKind::Mesh(ref m) if (m.rows, m.cols) == (1, 2)));
assert!(s.doc().unwrap().doc.node(e).is_none());
}
#[test]
fn expand_keeps_name_transparency_knockout_and_mask() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "bulge", "bend": 40}));
s.execute("transparency.set", &json!({"opacity": 70, "isolate": true, "knockout": "on"})).unwrap();
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
s.edit("name and mask", |d, _| {
let n = d.node_mut(e).unwrap();
n.name = Some("Banner".into());
n.mask = Some(Box::new(OpacityMask::new(mask, true)));
Ok(())
})
.unwrap();
s.execute("object.envelope.expand", &json!({})).unwrap();
let g = node(&s, e);
assert!(matches!(g.kind, NodeKind::Group { .. }));
assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true));
}
#[test]
fn object_expand_expands_envelopes_inside_the_selection() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50}));
let other = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
sel(&mut s, &[e, other]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!(info["object"], json!(true));
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let mut envelopes = 0;
node(&s, g).walk(&mut |n| envelopes += matches!(n.kind, NodeKind::Envelope { .. }) as usize);
assert_eq!(envelopes, 0);
// The arch lifted the expanded art's middle above the rectangle's top edge.
let b = node(&s, e).geometric_bounds().unwrap();
assert!(b.y0 < 99.0, "{b:?}");
}
#[test]
fn envelope_options_round_trip_natively_and_old_files_load() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({}));
s.execute("object.envelope.options", &json!({"preserveShape": "transparency", "distortPatternFills": true})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(back.node(e), d.node(e));
// An envelope saved before the options existed loads with its old behaviour.
let mut old = serde_json::to_value(node(&s, e)).unwrap();
old["kind"].as_object_mut().unwrap().remove("options");
let n: Node = serde_json::from_value(old).unwrap();
assert!(matches!(n.kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::default() && !options.distort_appearance));
// The old behaviour's options aren't written.
let mut legacy = node(&s, e);
if let NodeKind::Envelope { options, .. } = &mut legacy.kind {
*options = EnvelopeOptions::default();
}
assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none());
}