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 09:33:52 +07:00
parent 6994f248b2
commit fe6d6640fb
12 changed files with 1050 additions and 79 deletions

View File

@ -94,6 +94,81 @@ pub enum EnvelopeKind {
TopObject { path: PathData }, TopObject { path: PathData },
} }
/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreserveShape {
#[default]
ClippingMask,
Transparency,
}
impl PreserveShape {
pub fn id(self) -> &'static str {
match self {
Self::ClippingMask => "clippingMask",
Self::Transparency => "transparency",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"clippingMask" => Some(Self::ClippingMask),
"transparency" => Some(Self::Transparency),
_ => None,
}
}
}
/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options
/// existed do (their appearance applies after the distortion); new envelopes start from
/// [`EnvelopeOptions::NEW`], as in the reference app.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct EnvelopeOptions {
/// Rasters: Anti-Alias.
pub anti_alias: bool,
/// Rasters: Preserve Shape Using.
pub preserve_shape: PreserveShape,
/// Distort Appearance: strokes and live effects are applied before the distortion, so they
/// bend with the art (off: they apply to the distorted paths).
pub distort_appearance: bool,
/// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art.
pub distort_linear_gradients: bool,
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
pub distort_pattern_fills: bool,
}
impl Default for EnvelopeOptions {
fn default() -> Self {
Self {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
}
}
}
impl EnvelopeOptions {
/// The options a new envelope gets (the reference app's defaults).
pub const NEW: Self = Self {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: true,
distort_linear_gradients: false,
distort_pattern_fills: false,
};
/// Do gradients bend with the art?
pub fn gradients(&self) -> bool {
self.distort_appearance && self.distort_linear_gradients
}
/// Do pattern tiles bend with the art?
pub fn patterns(&self) -> bool {
self.distort_appearance && self.distort_pattern_fills
}
}
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. /// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
pub const H_RIGHT: usize = 0; pub const H_RIGHT: usize = 0;
pub const H_LEFT: usize = 1; pub const H_LEFT: usize = 1;
@ -1392,6 +1467,16 @@ pub fn envelope_expand(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64
content.iter().map(|c| map_node(c, &*f, piece, outline)).collect() content.iter().map(|c| map_node(c, &*f, piece, outline)).collect()
} }
/// The surface an envelope maps `content` onto, sampled on a `rows`×`cols` grid as a mesh whose
/// handles follow the surface and whose points are `color`: Release gives it back as the
/// envelope's shape and Reset with Mesh starts from it. `None` when the content has no bounds.
pub fn envelope_surface(content: &[Arc<Node>], kind: &EnvelopeKind, rows: u32, cols: u32, color: Color) -> Option<GradientMesh> {
let src = nodes_bounds(content)?;
let f = envelope_mapper(kind, src);
let at = |u: f64, v: f64| f(Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()));
Some(GradientMesh::from_surface(rows, cols, &at, &|_, _| color))
}
/// Envelope bounds (the image of the content box, sampled). /// Envelope bounds (the image of the content box, sampled).
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Rect> { pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Rect> {
match kind { match kind {
@ -1467,6 +1552,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
g.isolate = n.isolate; g.isolate = n.isolate;
g.knockout = n.knockout; g.knockout = n.knockout;
g.knockout_shape = n.knockout_shape; g.knockout_shape = n.knockout_shape;
g.mask = n.mask.clone();
g g
} }

View File

@ -272,6 +272,9 @@ pub enum NodeKind {
/// Edit Contents mode (the content, not the envelope, is edited). /// Edit Contents mode (the content, not the envelope, is edited).
#[serde(default)] #[serde(default)]
editing: bool, editing: bool,
/// Envelope Options besides Fidelity.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
options: crate::live::EnvelopeOptions,
}, },
/// Gradient mesh. /// Gradient mesh.
Mesh(GradientMesh), Mesh(GradientMesh),

View File

@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…", "Expand…",
["Object"], ["Object"],
None, None,
"{object?: true (type → outlines, live shapes → paths, effects baked), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", "{object?: true (type → outlines, live shapes → paths, effects baked, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection, has_selection,
expand expand
), ),
@ -84,6 +84,8 @@ struct Expandable {
effects: bool, effects: bool,
fill: bool, fill: bool,
stroke: bool, stroke: bool,
/// An envelope (Object expands it into its distorted content).
envelope: bool,
} }
impl Expandable { impl Expandable {
@ -95,6 +97,7 @@ impl Expandable {
e.text |= matches!(c.kind, NodeKind::Text(_)); e.text |= matches!(c.kind, NodeKind::Text(_));
e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
e.effects |= !c.appearance.effects.is_empty(); e.effects |= !c.appearance.effects.is_empty();
e.envelope |= matches!(c.kind, NodeKind::Envelope { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind { e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@ -106,7 +109,7 @@ impl Expandable {
} }
fn object(&self) -> bool { fn object(&self) -> bool {
self.text || self.live || self.effects self.text || self.live || self.effects || self.envelope
} }
} }
@ -132,6 +135,15 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
if object && e.effects { if object && e.effects {
let _ = run_raw(s, "effect.expandAppearance", &json!({})); let _ = run_raw(s, "effect.expandAppearance", &json!({}));
} }
if object && e.envelope {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| {
for r in &roots {
super::live::expand_envelopes_under(d, *r)?;
}
Ok(())
})?;
}
if object && e.live { if object && e.live {
let roots = selected_roots(s)?; let roots = selected_roots(s)?;
s.edit("Expand", |d, _| { s.edit("Expand", |d, _| {

View File

@ -9,7 +9,9 @@ use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshAppearance, Spine}; use vectorcraft_doc::live::{
self, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeOptions, GradientMesh, MeshAppearance, PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point}; use vectorcraft_geom::{Affine, PathData, Point};
@ -89,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make with Warp…", "Make with Warp…",
["Object", "Envelope Distort"], ["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"), Some("Cmd+Alt+Shift+W"),
"{ids?, style?: arc|arcLower|…|twist (arc), bend?: % (50), h?: %, v?: %, horizontal?: bool (true)} envelope the selected objects with a warp → {id}", "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}",
has_selection, has_selection,
env_make_warp env_make_warp
), ),
@ -98,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make with Mesh…", "Make with Mesh…",
["Object", "Envelope Distort"], ["Object", "Envelope Distort"],
Some("Cmd+Alt+M"), Some("Cmd+Alt+M"),
"{ids?, rows?: n (4), cols?: n (4)} envelope the selected objects with a point mesh → {id}", "{ids?, rows?: 1..50 (4), cols?: 1..50 (4)} envelope the selected objects with a mesh of rows × cols patches → {id}",
has_selection, has_selection,
env_make_mesh env_make_mesh
), ),
@ -111,12 +113,39 @@ pub fn specs() -> Vec<CommandSpec> {
has_multi, has_multi,
env_make_top env_make_top
), ),
cmd!(
"object.envelope.resetWithWarp",
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
cmd!(
"object.envelope.resetWithMesh",
"Reset with Mesh…",
["Object", "Envelope Distort"],
None,
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content)} make the selected envelopes mesh envelopes, keeping their content",
has_envelope,
env_reset_mesh
),
cmd!(
query "object.envelope.info",
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}",
has_doc,
env_info
),
cmd!( cmd!(
"object.envelope.release", "object.envelope.release",
"Release", "Release",
["Object", "Envelope Distort"], ["Object", "Envelope Distort"],
None, None,
"{} release the selected envelopes → {ids}", "{} release the selected envelopes into their content and their shape (a grey gradient mesh for warp and mesh envelopes, the path for a top-object envelope; the content keeps the envelope's opacity, blend mode and opacity mask) → {ids}",
has_envelope, has_envelope,
env_release env_release
), ),
@ -125,8 +154,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Options…", "Envelope Options…",
["Object", "Envelope Distort"], ["Object", "Envelope Distort"],
None, None,
"{fidelity?: 0..100, style?, bend?, h?, v?, horizontal?} set envelope options (warp params for warp envelopes)", "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_envelope, has_doc,
env_options env_options
), ),
cmd!( cmd!(
@ -134,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand", "Expand",
["Object", "Envelope Distort"], ["Object", "Envelope Distort"],
None, None,
"{} replace the selected envelopes by their distorted content → {ids}", "{} replace the selected envelopes by groups of their distorted content (type outlined), keeping name, opacity, blend mode, isolation, knockout and opacity mask → {ids}",
has_envelope, has_envelope,
env_expand env_expand
), ),
@ -526,8 +555,23 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
} }
fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind) -> Node { /// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
Node::new(id, NodeKind::Envelope { content, kind, fidelity: live::default_fidelity(), editing: false }) fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) {
s.envelope_defaults.unwrap_or((EnvelopeOptions::NEW, live::default_fidelity()))
}
fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node {
Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options })
}
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
let rows = f64_or(p, "rows", default.0 as f64);
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
return Err(bad(cmd, "rows and cols must be 1..50"));
}
Ok((rows as u32, cols as u32))
} }
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> { fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
@ -536,26 +580,30 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
if roots.is_empty() { if roots.is_empty() {
return Err(EngineError::Other("nothing selected".into())); return Err(EngineError::Other("nothing selected".into()));
} }
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind)))?; let defaults = envelope_defaults(s);
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
Ok(json!({ "id": id.0 })) Ok(json!({ "id": id.0 }))
} }
/// A mesh envelope of `rows`×`cols` patches over `content`: following the surface of `shape` (an
/// envelope's current kind), or a flat grid over the content's bounds.
fn mesh_kind(content: &[Arc<Node>], shape: Option<&EnvelopeKind>, rows: u32, cols: u32) -> Option<EnvelopeKind> {
let points = match shape {
Some(k) => live::envelope_surface(content, k, rows, cols, Color::BLACK)?.points.into_iter().map(|m| m.p).collect(),
None => live::grid_points(live::nodes_bounds(content)?, rows, cols),
};
Some(EnvelopeKind::Mesh { rows, cols, points })
}
fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> { fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.makeWithMesh"; const C: &str = "object.envelope.makeWithMesh";
let rows = f64_or(p, "rows", 4.0); let (rows, cols) = rows_cols(p, C, (4, 4))?;
let cols = f64_or(p, "cols", f64_or(p, "columns", 4.0));
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
return Err(bad(C, "rows and cols must be 1..50"));
}
let roots = roots_param(s, p)?; let roots = roots_param(s, p)?;
let st = s.doc()?; let st = s.doc()?;
let src = roots let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
.iter() let kind = mesh_kind(&nodes, None, rows, cols).ok_or_else(|| bad(C, "selection has no bounds"))?;
.filter_map(|id| st.doc.node(*id)) let defaults = envelope_defaults(s);
.fold(None, |a, n| vectorcraft_geom::union_opt(a, n.geometric_bounds())) let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
.ok_or_else(|| bad(C, "selection has no bounds"))?;
let kind = EnvelopeKind::Mesh { rows: rows as u32, cols: cols as u32, points: live::grid_points(src, rows as u32, cols as u32) };
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind)))?;
Ok(json!({ "id": id.0 })) Ok(json!({ "id": id.0 }))
} }
@ -579,14 +627,145 @@ fn env_make_top(s: &mut Session, p: &Value) -> Result<Value> {
let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) }; let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) };
let content = content.to_vec(); let content = content.to_vec();
let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?;
let defaults = envelope_defaults(s);
let id = s.edit("Make Envelope", |d, sel| { let id = s.edit("Make Envelope", |d, sel| {
let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }))?; let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?;
d.remove(top)?; d.remove(top)?;
Ok(id) Ok(id)
})?; })?;
Ok(json!({ "id": id.0 })) Ok(json!({ "id": id.0 }))
} }
/// The kind `f` makes of each selected envelope (from its content and its current kind).
fn reshaped(s: &Session, f: impl Fn(&[Arc<Node>], &EnvelopeKind) -> Result<EnvelopeKind>) -> Result<Vec<(NodeId, EnvelopeKind)>> {
let doc = &s.doc()?.doc;
let mut out = vec![];
for e in selected_of(s, is_envelope) {
if let Some(NodeKind::Envelope { content, kind, .. }) = doc.node(e).map(|n| &n.kind) {
out.push((e, f(content, kind)?));
}
}
Ok(out)
}
/// Give each envelope of `kinds` its new kind (as one undo step `label`).
fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) -> Result<Value> {
s.edit(label, |d, _| {
for (e, k) in kinds {
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
*kind = k;
}
}
Ok(())
})?;
ok()
}
fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithWarp";
let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?;
set_kinds(s, "Reset with Warp", kinds)
}
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithMesh";
let maintain = bool_or(p, "maintainShape", true);
let kinds = reshaped(s, |content, kind| {
let own = match kind {
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
_ => (4, 4),
};
let (rows, cols) = rows_cols(p, C, own)?;
mesh_kind(content, maintain.then_some(kind), rows, cols).ok_or_else(|| bad(C, "the envelope's content has no bounds"))
})?;
set_kinds(s, "Reset with Mesh", kinds)
}
/// `v` with an envelope's options and fidelity added.
fn options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value {
v["fidelity"] = json!(fidelity);
v["antiAlias"] = json!(o.anti_alias);
v["preserveShape"] = json!(o.preserve_shape.id());
v["distortAppearance"] = json!(o.distort_appearance);
v["distortLinearGradients"] = json!(o.distort_linear_gradients);
v["distortPatternFills"] = json!(o.distort_pattern_fills);
v
}
/// `cur` with the options `p` sets.
fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeOptions> {
let preserve_shape = match str_param(p, "preserveShape") {
Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?,
None => cur.preserve_shape,
};
Ok(EnvelopeOptions {
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
preserve_shape,
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
})
}
fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind)));
let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else {
let (options, fidelity) = envelope_defaults(s);
return Ok(options_json(json!({ "id": null }), &options, fidelity));
};
let v = match kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
};
let mut v = options_json(v, options, *fidelity);
v["id"] = json!(id.0);
v["editing"] = json!(editing);
Ok(v)
}
/// What Release gives back as an envelope's shape: a top object's path, or the surface of a warp
/// or mesh envelope as a gradient mesh, painted grey (id 0).
fn envelope_shape(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Node> {
let grey = Color::gray(0.25);
let (rows, cols) = match kind {
EnvelopeKind::TopObject { path } => {
return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0)));
}
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
EnvelopeKind::Warp { .. } => (4, 4),
};
live::envelope_surface(content, kind, rows, cols, grey).map(|m| Node::new(NodeId(0), NodeKind::Mesh(m)))
}
/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity
/// mask: a single object without its own takes them, else a new group around the content does.
fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec<Node>) -> Vec<Node> {
if env.has_default_transparency() {
return nodes;
}
let carry = |n: &mut Node| {
n.opacity = env.opacity;
n.blend = env.blend;
n.isolate = env.isolate;
n.knockout = env.knockout;
n.knockout_shape = env.knockout_shape;
n.mask = env.mask.clone();
};
if let [one] = nodes.as_mut_slice()
&& one.has_default_transparency()
{
carry(one);
return nodes;
}
let mut g = Node::group(d.alloc_id(), nodes.into_iter().map(Arc::new).collect());
carry(&mut g);
vec![g]
}
fn env_release(s: &mut Session, _: &Value) -> Result<Value> { fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
let envs = selected_of(s, is_envelope); let envs = selected_of(s, is_envelope);
let ids = s.edit("Release Envelope", |d, sel| { let ids = s.edit("Release Envelope", |d, sel| {
@ -594,15 +773,10 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
for e in &envs { for e in &envs {
let Some(n) = d.node(*e).cloned() else { continue }; let Some(n) = d.node(*e).cloned() else { continue };
let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue }; let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue };
let mut nodes: Vec<Node> = content.iter().map(|c| (**c).clone()).collect(); let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect());
// The envelope shape comes back as a grey path above the content. if let Some(mut shape) = envelope_shape(content, kind) {
let shape = match kind { shape.id = d.alloc_id();
EnvelopeKind::TopObject { path } => Some(path.clone()), nodes.push(shape);
EnvelopeKind::Mesh { .. } | EnvelopeKind::Warp { .. } => None,
};
if let Some(path) = shape {
let id = d.alloc_id();
nodes.push(Node::path(id, path, Appearance::basic(Paint::solid(Color::gray(0.25)), Paint::None, 0.0)));
} }
out.extend(replace_with(d, *e, nodes)?); out.extend(replace_with(d, *e, nodes)?);
} }
@ -614,28 +788,33 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
fn env_options(s: &mut Session, p: &Value) -> Result<Value> { fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.options"; const C: &str = "object.envelope.options";
let envs = selected_of(s, is_envelope);
let fid = p.get("fidelity").and_then(Value::as_f64); let fid = p.get("fidelity").and_then(Value::as_f64);
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
return Err(bad(C, "fidelity must be 0..100")); return Err(bad(C, "fidelity must be 0..100"));
} }
// Validate warp params up front. let envs = selected_of(s, is_envelope);
if envs.is_empty() {
// No envelope selected: the options new envelopes get.
let (o, f) = envelope_defaults(s);
s.envelope_defaults = Some((options_param(p, C, o)?, fid.unwrap_or(f)));
return ok();
}
// Validate everything up front.
let doc = &s.doc()?.doc; let doc = &s.doc()?.doc;
let mut kinds = vec![]; let mut updates = vec![];
for e in &envs { for e in &envs {
if let Some(NodeKind::Envelope { kind: k @ EnvelopeKind::Warp { .. }, .. }) = doc.node(*e).map(|n| &n.kind) { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) {
kinds.push((*e, warp_kind(p, C, Some(k))?)); let kind = match kind {
EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(kind))?,
k => k.clone(),
};
updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity)));
} }
} }
s.edit("Envelope Options", |d, _| { s.edit("Envelope Options", |d, _| {
for e in &envs { for (e, k, o, f) in updates {
if let Some(NodeKind::Envelope { fidelity, kind, .. }) = d.node_mut(*e).map(|n| &mut n.kind) { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
if let Some(f) = fid { (*kind, *options, *fidelity) = (k, o, f);
*fidelity = f;
}
if let Some((_, k)) = kinds.iter().find(|(id, _)| id == e) {
*kind = k.clone();
}
} }
} }
Ok(()) Ok(())
@ -643,21 +822,49 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
ok() ok()
} }
/// Replace envelope `id` by a group of its distorted content (type outlined) with its id, name,
/// transparency and opacity mask; the generated pieces get ids of their own.
fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let hook: &dyn Fn(&Node) -> Option<Node> = &vectorcraft_render::effects::outline_text;
let mut g = live::expanded_group(&n, Some(hook));
fix_ids(d, &mut g);
// Outlined type repeats its object's id on its pieces.
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object → Expand: every envelope in `root`'s subtree (itself included) expanded
/// ([`expand_envelope`]; envelopes inside envelopes go with them). Returns how many.
pub(super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result<usize> {
fn collect(n: &Node, out: &mut Vec<NodeId>) {
if is_envelope(n) {
out.push(n.id);
} else {
for c in n.children().into_iter().flatten() {
collect(c, out);
}
}
}
let mut envs = vec![];
if let Some(n) = d.node(root) {
collect(n, &mut envs);
}
for e in &envs {
expand_envelope(d, *e)?;
}
Ok(envs.len())
}
fn env_expand(s: &mut Session, _: &Value) -> Result<Value> { fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
let envs = selected_of(s, is_envelope); let envs = selected_of(s, is_envelope);
let ids = s.edit("Expand Envelope", |d, sel| { let ids = s.edit("Expand Envelope", |d, sel| {
let mut out = vec![];
for e in &envs { for e in &envs {
let Some(n) = d.node(*e).cloned() else { continue }; expand_envelope(d, *e)?;
let children = vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect();
let mut g = Node::new(n.id, NodeKind::Group { children, clip: false });
g.opacity = n.opacity;
g.blend = n.blend;
fix_ids(d, &mut g);
out.extend(replace_with(d, *e, vec![g])?);
} }
sel.set(out.iter().copied()); sel.set(envs.iter().copied());
Ok(out) Ok(envs.clone())
})?; })?;
Ok(ids_json(&ids)) Ok(ids_json(&ids))
} }
@ -705,18 +912,14 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> { fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.create"; const C: &str = "object.mesh.create";
let rows = f64_or(p, "rows", 4.0); let (rows, cols) = rows_cols(p, C, (4, 4))?;
let cols = f64_or(p, "cols", f64_or(p, "columns", 4.0));
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
return Err(bad(C, "rows and cols must be 1..50"));
}
let app = match str_param(p, "appearance") { let app = match str_param(p, "appearance") {
Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?,
None => MeshAppearance::Flat, None => MeshAppearance::Flat,
}; };
let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0);
let at = point_param(p, "at"); let at = point_param(p, "at");
let (rows, cols) = if at.is_some() { (1, 1) } else { (rows as u32, cols as u32) }; let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) };
let roots = roots_param(s, p)?; let roots = roots_param(s, p)?;
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
let mut out = vec![]; let mut out = vec![];

View File

@ -683,6 +683,9 @@ pub struct Session {
/// While the active tool's actions run: their commands aren't "a command from outside the /// While the active tool's actions run: their commands aren't "a command from outside the
/// tool" ([`Session::after_command`]). /// tool" ([`Session::after_command`]).
pub(crate) in_tool_actions: bool, pub(crate) in_tool_actions: bool,
/// Envelope Options with no envelope selected: the options and fidelity new envelopes get
/// (`None`: the reference app's defaults, fidelity 50); not saved.
pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
} }
impl Default for Session { impl Default for Session {
@ -724,6 +727,7 @@ impl Session {
batch_stash: None, batch_stash: None,
recovery: Default::default(), recovery: Default::default(),
in_tool_actions: false, in_tool_actions: false,
envelope_defaults: None,
} }
} }
@ -1164,6 +1168,8 @@ mod tests_effectedit;
#[cfg(test)] #[cfg(test)]
mod tests_emptytype; mod tests_emptytype;
#[cfg(test)] #[cfg(test)]
mod tests_envelope;
#[cfg(test)]
mod tests_expand; mod tests_expand;
#[cfg(test)] #[cfg(test)]
mod tests_eyedropper; mod tests_eyedropper;

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());
}

View File

@ -363,3 +363,47 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() {
} }
} }
} }
/// Envelope Distort's commands on warp, mesh and top-object envelopes (selected, and with their
/// content selected in Edit Contents) with `{}` and junk params: never a panic, an open
/// interaction or a broken tree.
#[test]
fn envelope_commands_with_junk_params_on_envelopes() {
let path = safe_path();
let makes: [(&str, Value); 3] = [
("object.envelope.makeWithWarp", json!({"style": "twist", "bend": 80})),
("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})),
("object.envelope.makeWithTopObject", json!({})),
];
let mut failures = vec![];
for (make, mp) in &makes {
for editing in [false, true] {
let ids: Vec<&str> =
command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect();
for id in ids {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
let mut s = Fixture::Multi.session();
if s.execute(make, mp).is_err() {
failures.push(format!("{make} failed on the Multi fixture"));
continue;
}
if editing {
let _ = s.execute("object.envelope.editContents", &json!({"editing": true}));
}
let r = catch_quiet(|| s.execute(id, &p));
if r.is_err() {
failures.push(format!("PANIC {id} {p} [{make}, editing {editing}]"));
} else if s.in_interaction() {
failures.push(format!("{id} {p} [{make}]: left an interaction open"));
} else if let Err(e) = check_session(&s) {
failures.push(format!("{id} {p} [{make}]: {e}"));
}
}
}
}
}
failures.sort();
failures.dedup();
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
}

View File

@ -0,0 +1,346 @@
//! Object → Envelope Distort's dialogs, previewed live on the canvas; OK keeps the preview as one
//! undo step:
//!
//! - **Warp Options** (`envelopeWarp`): Make with Warp, or Reset with Warp for a selected envelope
//! (`reset`). Fields `style` (one of the 15 warp styles), `horizontal` (false: Vertical),
//! `bend`, `h` and `v` (%, -100..100).
//! - **Envelope Mesh** (`envelopeMesh`): Make with Mesh, or Reset with Mesh (`reset`, with
//! `maintainShape`). Fields `rows`, `cols` (1..50).
//! - **Envelope Options** (`envelopeOptions`): the selected envelopes' options, or with none
//! selected the options new envelopes get (no preview then). Fields `antiAlias`,
//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100),
//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`.
//!
//! Every dialog also has `preview`. They open with the selected envelope's values
//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh
//! open the Reset variant while an envelope is selected, as the reference app's do.
use serde_json::{Value, json};
use vectorcraft_render::effects::WARP_STYLES;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kinds.
pub const WARP: &str = "envelopeWarp";
pub const MESH: &str = "envelopeMesh";
pub const OPTIONS: &str = "envelopeOptions";
const MAKE_WARP: &str = "object.envelope.makeWithWarp";
const RESET_WARP: &str = "object.envelope.resetWithWarp";
const MAKE_MESH: &str = "object.envelope.makeWithMesh";
const RESET_MESH: &str = "object.envelope.resetWithMesh";
const OPTIONS_CMD: &str = "object.envelope.options";
/// Width of the label column.
const LABEL_W: f32 = 84.0;
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
fn heading(d: &Dialog) -> String {
match d.kind.as_str() {
WARP => "Warp Options",
MESH if d.bool("reset") => "Reset Envelope Mesh",
MESH => "Envelope Mesh",
_ => "Envelope Options",
}
.into()
}
/// Does the menu item of command `id` open one of these dialogs?
pub fn opens(id: &str) -> bool {
matches!(id, MAKE_WARP | RESET_WARP | MAKE_MESH | RESET_MESH | OPTIONS_CMD)
}
/// Open the dialog command `id`'s menu item opens, with the selected envelope's values.
pub fn open(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
let info = app.session.execute("object.envelope.info", &json!({})).map_err(|e| e.to_string())?;
let envelope = !info["id"].is_null();
if matches!(id, RESET_WARP | RESET_MESH) && !envelope {
return Err("select an envelope".into());
}
let num = |k: &str, d: f64| info[k].as_f64().unwrap_or(d);
let (kind, fields) = match id {
MAKE_WARP | RESET_WARP => {
let warp = info["type"] == "warp";
let own = |k: &str, d: f64| if warp { num(k, d) } else { d };
let style = if warp { info["style"].clone() } else { json!("arc") };
let horizontal = !warp || info["horizontal"] != false;
(
WARP,
json!({"reset": envelope, "style": style, "horizontal": horizontal, "bend": own("bend", 50.0), "h": own("h", 0.0), "v": own("v", 0.0), "preview": true}),
)
}
MAKE_MESH | RESET_MESH => {
let mesh = info["type"] == "mesh";
let own = |k: &str| if mesh { num(k, 4.0) } else { 4.0 };
(MESH, json!({"reset": envelope, "rows": own("rows"), "cols": own("cols"), "maintainShape": true, "preview": true}))
}
OPTIONS_CMD => {
let mut f = json!({"__envelope": envelope, "preview": envelope});
for k in ["antiAlias", "preserveShape", "fidelity", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
f[k] = info[k].clone();
}
(OPTIONS, f)
}
_ => return Err(format!("`{id}` has no envelope dialog")),
};
app.ui.dialog = Some(Dialog::new(kind, fields));
Ok(Value::Null)
}
/// The command OK runs, its parameters and the preview's undo label.
fn command(d: &Dialog) -> (&'static str, Value, &'static str) {
let reset = d.bool("reset");
match d.kind.as_str() {
WARP => {
let p = json!({"style": d.str("style"), "horizontal": d.bool("horizontal"), "bend": d.f64("bend", 50.0), "h": d.f64("h", 0.0), "v": d.f64("v", 0.0)});
if reset { (RESET_WARP, p, "Reset with Warp") } else { (MAKE_WARP, p, "Make Envelope") }
}
MESH => {
let p = json!({"rows": d.f64("rows", 4.0), "cols": d.f64("cols", 4.0)});
if reset {
let mut p = p;
p["maintainShape"] = json!(d.bool("maintainShape"));
(RESET_MESH, p, "Reset with Mesh")
} else {
(MAKE_MESH, p, "Make Envelope")
}
}
_ => {
let mut p = json!({"fidelity": d.f64("fidelity", 50.0), "preserveShape": d.str("preserveShape")});
for k in ["antiAlias", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
p[k] = json!(d.bool(k));
}
(OPTIONS_CMD, p, "Envelope Options")
}
}
}
fn set(d: &mut Dialog, key: &str, v: Value) {
d.fields.insert(key.into(), v);
}
/// A −100..100 % slider row.
fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail);
}
/// A whole number field row clamped to 1..50.
fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
widgets::label_row(ui, label, LABEL_W, |ui| {
if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) {
set(d, key, json!(n.round().clamp(1.0, 50.0)));
}
});
}
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
let horizontal = d.bool("horizontal");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
if widgets::radio(ui, label, horizontal == h, true) {
set(d, "horizontal", json!(h));
}
ui.add_space(10.0);
}
});
percent(ui, d, "bend", "Bend:");
ui.add_space(6.0);
widgets::subheader(ui, "Distortion");
percent(ui, d, "h", "Horizontal:");
percent(ui, d, "v", "Vertical:");
}
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
count(ui, d, "rows", "Rows:");
count(ui, d, "cols", "Columns:");
if d.bool("reset") {
ui.add_space(4.0);
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
}
}
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::subheader(ui, "Rasters");
ui.horizontal(|ui| {
ui.add_space(12.0);
form::check(ui, d, "antiAlias", "Anti-Alias");
});
ui.add_space(4.0);
widgets::subheader(ui, "Preserve Shape Using:");
let clip = d.str("preserveShape") != "transparency";
ui.horizontal(|ui| {
ui.add_space(12.0);
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
set(d, "preserveShape", json!(value));
}
ui.add_space(10.0);
}
});
ui.add_space(4.0);
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
ui.add_space(4.0);
form::check(ui, d, "distortAppearance", "Distort Appearance");
// Gradients and patterns bend only with the appearance.
let appearance = d.bool("distortAppearance");
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
let on = d.bool(key);
ui.horizontal(|ui| {
ui.add_space(20.0);
if widgets::check(ui, label, on, appearance) {
set(d, key, json!(!on));
}
});
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
match d.kind.as_str() {
WARP => warp_body(ui, d),
MESH => mesh_body(ui, d),
_ => options_body(ui, d),
}
// With no envelope selected, Envelope Options sets the defaults: nothing to preview.
if d.kind != OPTIONS || d.bool("__envelope") {
let (cmd, p, label) = command(d);
form::preview(app, ui, d, label, cmd, p);
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p, _) = command(d);
form::commit_preview(app, cmd, p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::live::EnvelopeKind;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_engine::Session;
fn app_with_rect() -> (VectorcraftApp, NodeId) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
(app, NodeId(id))
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn envelope(app: &VectorcraftApp) -> (NodeId, vectorcraft_doc::Node) {
let st = app.session.doc().unwrap();
let id = st.selection.objects[0];
(id, st.doc.node(id).unwrap().clone())
}
#[test]
fn warp_dialog_previews_and_ok_keeps_one_step() {
let (mut app, rect) = app_with_rect();
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.fields["reset"].clone())), Some((WARP, json!(false))));
frame(&mut app);
// The preview made the envelope already.
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { .. }));
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("style".into(), json!("flag"));
d.fields.insert("horizontal".into(), json!(false));
d.fields.insert("bend".into(), json!(-30));
frame(&mut app);
let undo = app.session.doc().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let (_, n) = envelope(&app);
match &n.kind {
NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => {
assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false));
assert_eq!(content[0].id, rect);
}
k => panic!("{k:?}"),
}
// With the envelope selected, the same item resets it, starting from its values.
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0)));
crate::dialogs::cancel(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back");
}
#[test]
fn reset_items_take_the_make_items_place_while_an_envelope_is_selected() {
let labels = |app: &VectorcraftApp| -> Vec<String> {
crate::menus::menu_entries(app)
.into_iter()
.filter(|e| e.path.last().map(String::as_str) == Some("Envelope Distort"))
.map(|e| e.label)
.collect()
};
let (mut app, _) = app_with_rect();
let before = labels(&app);
assert!(before.contains(&"Make with Warp…".to_string()) && !before.contains(&"Reset with Warp…".to_string()), "{before:?}");
app.run(MAKE_MESH, json!({})).unwrap();
let after = labels(&app);
assert!(after.contains(&"Reset with Warp…".to_string()) && after.contains(&"Reset with Mesh…".to_string()), "{after:?}");
assert!(!after.contains(&"Make with Warp…".to_string()) && !after.contains(&"Make with Mesh…".to_string()), "{after:?}");
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_ok());
app.run("select.none", json!({})).unwrap();
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_err(), "nothing to reset");
}
#[test]
fn reset_mesh_dialog_switches_the_kind_and_cancel_rolls_back() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap();
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH));
frame(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed");
crate::dialogs::cancel(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }));
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2));
super::super::confirm(&mut app).unwrap();
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. }));
}
#[test]
fn options_dialog_starts_from_the_envelope_and_without_one_sets_the_defaults() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({})).unwrap();
app.run("object.envelope.options", json!({"fidelity": 80, "distortPatternFills": true})).unwrap();
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["fidelity"].clone(), d.fields["distortPatternFills"].clone()), (json!(80.0), json!(true)));
d.fields.insert("preserveShape".into(), json!("transparency"));
frame(&mut app);
super::super::confirm(&mut app).unwrap();
let info = app.session.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!((info["preserveShape"].clone(), info["fidelity"].clone()), (json!("transparency"), json!(80.0)));
// Nothing selected: the defaults for new envelopes, no preview.
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.fields["preview"], json!(false));
d.fields.insert("distortAppearance".into(), json!(false));
frame(&mut app);
assert!(!app.session.in_interaction());
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false));
}
}

View File

@ -18,6 +18,7 @@ mod document_setup;
pub mod dxf_import; pub mod dxf_import;
pub mod dxf_options; pub mod dxf_options;
mod effect; mod effect;
pub mod envelope;
pub mod eps_options; pub mod eps_options;
pub mod expand; pub mod expand;
mod export_as; mod export_as;
@ -251,6 +252,7 @@ registry! {
Plugin: [plugin::KIND] => plugin::SPEC, Plugin: [plugin::KIND] => plugin::SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC,
PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC,
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
} }
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).

View File

@ -366,7 +366,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.menuDialog", "ui.menuDialog",
"Menu Dialog", "Menu Dialog",
"", "",
"{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates)", "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)",
), ),
( (
"ui.widthPointEdit", "ui.widthPointEdit",
@ -891,12 +891,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"ui.expandDialog" => crate::dialogs::expand::open(app), "ui.expandDialog" => crate::dialogs::expand::open(app),
"attributes.openUrl" => crate::panels::attributes::open_url(app, p), "attributes.openUrl" => crate::panels::attributes::open_url(app, p),
"ui.spotColors" => crate::dialogs::spot_colors::open(app), "ui.spotColors" => crate::dialogs::spot_colors::open(app),
"ui.menuDialog" => match s("command").as_deref().and_then(menu_dialog) { "ui.menuDialog" => match s("command").as_deref() {
Some((kind, fields)) => { Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); c => match c.and_then(menu_dialog) {
Ok(Value::Null) Some((kind, fields)) => {
} app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), Ok(Value::Null)
}
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
},
}, },
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
@ -1080,6 +1083,17 @@ fn hidden_when_disabled(id: &str) -> bool {
|| id.starts_with(crate::panels::graphic_styles::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT)
} }
/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset
/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected.
fn swapped_out(app: &VectorcraftApp, id: &str) -> bool {
let reset = match id {
"object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false,
"object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true,
_ => return false,
};
enabled(app, "object.envelope.release") != reset
}
/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). /// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them).
const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [
"file.openRecent1", "file.openRecent1",
@ -1500,16 +1514,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
sub( sub(
"Envelope Distort", "Envelope Distort",
vec![ vec![
cp( // While an envelope is selected, Reset takes Make's place (`swapped_out`).
"Make with Warp…", c("Make with Warp…", "object.envelope.makeWithWarp"),
"object.envelope.makeWithWarp", c("Reset with Warp…", "object.envelope.resetWithWarp"),
json!({"style": "arc", "bend": 50, "h": 0, "v": 0, "horizontal": true}), c("Make with Mesh…", "object.envelope.makeWithMesh"),
), c("Reset with Mesh…", "object.envelope.resetWithMesh"),
cp("Make with Mesh…", "object.envelope.makeWithMesh", json!({"rows": 4, "cols": 4})),
c("Make with Top Object", "object.envelope.makeWithTopObject"), c("Make with Top Object", "object.envelope.makeWithTopObject"),
Sep, Sep,
c("Release", "object.envelope.release"), c("Release", "object.envelope.release"),
cp("Envelope Options…", "object.envelope.options", json!({"fidelity": 50})), c("Envelope Options…", "object.envelope.options"),
c("Expand", "object.envelope.expand"), c("Expand", "object.envelope.expand"),
Sep, Sep,
c("Edit Contents", "object.envelope.editContents"), c("Edit Contents", "object.envelope.editContents"),
@ -1936,7 +1949,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
Item::Cmd(label, id, p) => { Item::Cmd(label, id, p) => {
let en = enabled(app, id); let en = enabled(app, id);
// Unused saved-view and recent-file slots are hidden (only the real ones are listed). // Unused saved-view and recent-file slots are hidden (only the real ones are listed).
if !en && hidden_when_disabled(id) { if (!en && hidden_when_disabled(id)) || swapped_out(app, id) {
continue; continue;
} }
let label = dynamic_label(app, id, label); let label = dynamic_label(app, id, label);
@ -2008,6 +2021,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
return; return;
} }
// Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection.
if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::envelope::open(app, id) {
app.status(e);
}
return;
}
// Repeat Options: a dialog with the selected repeat's current values. // Repeat Options: a dialog with the selected repeat's current values.
if id == "object.repeat.options" if id == "object.repeat.options"
&& p.as_object().is_none_or(|o| o.is_empty()) && p.as_object().is_none_or(|o| o.is_empty())
@ -2146,7 +2166,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) { fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items { for it in items {
match it { match it {
Item::Cmd(_, id, _) if hidden_when_disabled(id) && !enabled(app, id) => {} Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
Item::Cmd(l, id, p) => out.push(MenuEntry { Item::Cmd(l, id, p) => out.push(MenuEntry {
path: path.clone(), path: path.clone(),
label: dynamic_label(app, id, l), label: dynamic_label(app, id, l),

View File

@ -455,3 +455,13 @@ dialog prefilled from the grid. Its fields are those of `perspective.grid.define
`gridline`, `angle`, `distance`, `horizonHeight`, `thirdVp`, `leftColor`, `rightColor`, `groundColor`, `opacity`; `gridline`, `angle`, `distance`, `horizonHeight`, `thirdVp`, `leftColor`, `rightColor`, `groundColor`, `opacity`;
lengths in `units`, so setting `units` converts them). `ui.dialog.confirm` runs `perspective.grid.define`; a refused lengths in `units`, so setting `units` converts them). `ui.dialog.confirm` runs `perspective.grid.define`; a refused
value keeps the dialog open. value keeps the dialog open.
Object → Envelope Distort's dialogs open through their menu items or `ui.menuDialog {command}`. Make with Warp…
(`object.envelope.makeWithWarp`) opens `envelopeWarp` (fields `style`, `horizontal`, `bend`, `h`, `v`, `preview`, and
`reset`); while an envelope is selected the menu shows Reset with Warp… instead, and the same item, shortcut or
`object.envelope.resetWithWarp` opens it with `reset: true` and the envelope's values. Make with Mesh… / Reset with
Mesh… open `envelopeMesh` (`rows`, `cols`, `reset`, and `maintainShape` when resetting). Envelope Options…
(`object.envelope.options`) opens `envelopeOptions` (`antiAlias`, `preserveShape`, `fidelity`, `distortAppearance`,
`distortLinearGradients`, `distortPatternFills`) with the selected envelope's values, or with nothing selected the
defaults for new envelopes. They preview live while an envelope is involved, and `ui.dialog.confirm` keeps the result
as one undo step.

View File

@ -1764,3 +1764,31 @@ the viewing angle, so the vanishing points sit at `x − distance/tan(angle)` an
{"name":"run_command","arguments":{"command":"perspective.grid.get","params":{}}} {"name":"run_command","arguments":{"command":"perspective.grid.get","params":{}}}
{"name":"run_command","arguments":{"command":"perspective.grid.define","params":{"units":"inches","gridline":0.5,"angle":30,"distance":6,"horizonHeight":3}}} {"name":"run_command","arguments":{"command":"perspective.grid.define","params":{"units":"inches","gridline":0.5,"angle":30,"distance":6,"horizonHeight":3}}}
``` ```
## Envelopes
Object → Envelope Distort wraps the selection in a live envelope: `object.envelope.makeWithWarp` (`style`: arc,
arcLower, arcUpper, arch, bulge, shellLower, shellUpper, flag, wave, fish, rise, fisheye, inflate, squeeze or twist;
`bend`, `h` and `v` in % from -100 to 100; `horizontal` or `orientation`), `object.envelope.makeWithMesh` (`rows`,
`cols`: 1–50) and `object.envelope.makeWithTopObject` (the topmost selected path becomes the envelope).
`object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and
the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected,
`object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps
the current shape) switch its kind and keep its content. `object.envelope.options` sets `fidelity` and the options
(`antiAlias`, `preserveShape`: clippingMask or transparency, `distortAppearance`, `distortLinearGradients`,
`distortPatternFills`; the last two need `distortAppearance`); with no envelope selected it sets them for new
envelopes. New envelopes start with Distort Appearance on, as in the reference app; envelopes saved before these
options existed keep their old look (appearance applied after the distortion).
`object.envelope.release` gives back the content and the envelope's shape (a grey gradient mesh for warp and mesh
envelopes, the path for a top-object envelope); the content keeps the envelope's opacity, blend mode and opacity mask
(a group around it carries them when there are several objects). `object.envelope.expand`, and `object.expand` with
`object` on, replace envelopes by groups of their distorted content that keep the name, transparency, knockout and
opacity mask.
```json
{"name":"run_command","arguments":{"command":"object.envelope.makeWithWarp","params":{"style":"arch","bend":40}}}
{"name":"run_command","arguments":{"command":"object.envelope.resetWithMesh","params":{"rows":3,"cols":3,"maintainShape":true}}}
{"name":"run_command","arguments":{"command":"object.envelope.options","params":{"fidelity":80,"distortAppearance":true,"distortLinearGradients":true}}}
{"name":"run_command","arguments":{"command":"object.envelope.info","params":{}}}
```