diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index fe0ee33d..50763981 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -94,6 +94,81 @@ pub enum EnvelopeKind { 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 { + 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. pub const H_RIGHT: usize = 0; pub const H_LEFT: usize = 1; @@ -1392,6 +1467,16 @@ pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64 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], kind: &EnvelopeKind, rows: u32, cols: u32, color: Color) -> Option { + 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). pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind) -> Option { match kind { @@ -1467,6 +1552,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node { g.isolate = n.isolate; g.knockout = n.knockout; g.knockout_shape = n.knockout_shape; + g.mask = n.mask.clone(); g } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 552caf04..63f09c78 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -272,6 +272,9 @@ pub enum NodeKind { /// Edit Contents mode (the content, not the envelope, is edited). #[serde(default)] editing: bool, + /// Envelope Options besides Fidelity. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + options: crate::live::EnvelopeOptions, }, /// Gradient mesh. Mesh(GradientMesh), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 8899a618..d42fe168 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], 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, expand ), @@ -84,6 +84,8 @@ struct Expandable { effects: bool, fill: bool, stroke: bool, + /// An envelope (Object expands it into its distorted content). + envelope: bool, } impl Expandable { @@ -95,6 +97,7 @@ impl Expandable { e.text |= matches!(c.kind, NodeKind::Text(_)); e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); e.effects |= !c.appearance.effects.is_empty(); + e.envelope |= matches!(c.kind, NodeKind::Envelope { .. }); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -106,7 +109,7 @@ impl Expandable { } 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 { if object && e.effects { 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 { let roots = selected_roots(s)?; s.edit("Expand", |d, _| { diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 0aa83dbd..f3d05c87 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use serde_json::{Value, json}; 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_geom::{Affine, PathData, Point}; @@ -89,7 +91,7 @@ pub fn specs() -> Vec { "Make with Warp…", ["Object", "Envelope Distort"], 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, env_make_warp ), @@ -98,7 +100,7 @@ pub fn specs() -> Vec { "Make with Mesh…", ["Object", "Envelope Distort"], 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, env_make_mesh ), @@ -111,12 +113,39 @@ pub fn specs() -> Vec { has_multi, 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!( "object.envelope.release", "Release", ["Object", "Envelope Distort"], 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, env_release ), @@ -125,8 +154,8 @@ pub fn specs() -> Vec { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, style?, bend?, h?, v?, horizontal?} set envelope options (warp params for warp envelopes)", - has_envelope, + "{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_doc, env_options ), cmd!( @@ -134,7 +163,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Envelope Distort"], 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, env_expand ), @@ -526,8 +555,23 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result>, kind: EnvelopeKind) -> Node { - Node::new(id, NodeKind::Envelope { content, kind, fidelity: live::default_fidelity(), editing: false }) +/// The options and fidelity new envelopes get (Envelope Options with no envelope selected). +fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { + s.envelope_defaults.unwrap_or((EnvelopeOptions::NEW, live::default_fidelity())) +} + +fn envelope(id: NodeId, content: Vec>, 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 { @@ -536,26 +580,30 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result { if roots.is_empty() { 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 })) } +/// 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], shape: Option<&EnvelopeKind>, rows: u32, cols: u32) -> Option { + 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 { const C: &str = "object.envelope.makeWithMesh"; - let rows = f64_or(p, "rows", 4.0); - 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 (rows, cols) = rows_cols(p, C, (4, 4))?; let roots = roots_param(s, p)?; let st = s.doc()?; - let src = roots - .iter() - .filter_map(|id| st.doc.node(*id)) - .fold(None, |a, n| vectorcraft_geom::union_opt(a, n.geometric_bounds())) - .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)))?; + let nodes: Vec> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); + let kind = mesh_kind(&nodes, None, rows, cols).ok_or_else(|| bad(C, "selection has no bounds"))?; + 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 })) } @@ -579,14 +627,145 @@ fn env_make_top(s: &mut Session, p: &Value) -> Result { 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 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 = 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)?; Ok(id) })?; 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], &EnvelopeKind) -> Result) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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], kind: &EnvelopeKind) -> Option { + 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) -> Vec { + 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 { let envs = selected_of(s, is_envelope); let ids = s.edit("Release Envelope", |d, sel| { @@ -594,15 +773,10 @@ fn env_release(s: &mut Session, _: &Value) -> Result { for e in &envs { let Some(n) = d.node(*e).cloned() else { continue }; let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue }; - let mut nodes: Vec = content.iter().map(|c| (**c).clone()).collect(); - // The envelope shape comes back as a grey path above the content. - let shape = match kind { - EnvelopeKind::TopObject { path } => Some(path.clone()), - 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))); + let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect()); + if let Some(mut shape) = envelope_shape(content, kind) { + shape.id = d.alloc_id(); + nodes.push(shape); } out.extend(replace_with(d, *e, nodes)?); } @@ -614,28 +788,33 @@ fn env_release(s: &mut Session, _: &Value) -> Result { fn env_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.options"; - let envs = selected_of(s, is_envelope); let fid = p.get("fidelity").and_then(Value::as_f64); if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { 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 mut kinds = vec![]; + let mut updates = vec![]; for e in &envs { - if let Some(NodeKind::Envelope { kind: k @ EnvelopeKind::Warp { .. }, .. }) = doc.node(*e).map(|n| &n.kind) { - kinds.push((*e, warp_kind(p, C, Some(k))?)); + if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { + 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, _| { - for e in &envs { - if let Some(NodeKind::Envelope { fidelity, kind, .. }) = d.node_mut(*e).map(|n| &mut n.kind) { - if let Some(f) = fid { - *fidelity = f; - } - if let Some((_, k)) = kinds.iter().find(|(id, _)| id == e) { - *kind = k.clone(); - } + for (e, k, o, f) in updates { + if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + (*kind, *options, *fidelity) = (k, o, f); } } Ok(()) @@ -643,21 +822,49 @@ fn env_options(s: &mut Session, p: &Value) -> Result { 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 = &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 { + fn collect(n: &Node, out: &mut Vec) { + 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 { let envs = selected_of(s, is_envelope); let ids = s.edit("Expand Envelope", |d, sel| { - let mut out = vec![]; for e in &envs { - let Some(n) = d.node(*e).cloned() else { continue }; - 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])?); + expand_envelope(d, *e)?; } - sel.set(out.iter().copied()); - Ok(out) + sel.set(envs.iter().copied()); + Ok(envs.clone()) })?; Ok(ids_json(&ids)) } @@ -705,18 +912,14 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { fn mesh_create(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.create"; - let rows = f64_or(p, "rows", 4.0); - 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 (rows, cols) = rows_cols(p, C, (4, 4))?; let app = match str_param(p, "appearance") { Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, None => MeshAppearance::Flat, }; let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); 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 (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { let mut out = vec![]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 78e9bf54..be8d1e33 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -683,6 +683,9 @@ pub struct Session { /// While the active tool's actions run: their commands aren't "a command from outside the /// tool" ([`Session::after_command`]). 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 { @@ -724,6 +727,7 @@ impl Session { batch_stash: None, recovery: Default::default(), in_tool_actions: false, + envelope_defaults: None, } } @@ -1164,6 +1168,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_emptytype; #[cfg(test)] +mod tests_envelope; +#[cfg(test)] mod tests_expand; #[cfg(test)] mod tests_eyedropper; diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs new file mode 100644 index 00000000..54e000f9 --- /dev/null +++ b/crates/engine/src/tests_envelope.rs @@ -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::>()})).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, ¶ms).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 = 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![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()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index e28c2876..a4584c7a 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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::>().join("\n")); +} diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs new file mode 100644 index 00000000..24b62774 --- /dev/null +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -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 { + 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 { + 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 { + 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)); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 18d6a087..c80d0d2c 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -18,6 +18,7 @@ mod document_setup; pub mod dxf_import; pub mod dxf_options; mod effect; +pub mod envelope; pub mod eps_options; pub mod expand; mod export_as; @@ -251,6 +252,7 @@ registry! { Plugin: [plugin::KIND] => plugin::SPEC, VectorHalftone: [halftone::KIND] => halftone::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). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7fd65ab7..4372693e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -366,7 +366,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.menuDialog", "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", @@ -891,12 +891,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::expand::open(app), "attributes.openUrl" => crate::panels::attributes::open_url(app, p), "ui.spotColors" => crate::dialogs::spot_colors::open(app), - "ui.menuDialog" => match s("command").as_deref().and_then(menu_dialog) { - Some((kind, fields)) => { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - Ok(Value::Null) - } - None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + "ui.menuDialog" => match s("command").as_deref() { + Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), + c => match c.and_then(menu_dialog) { + Some((kind, fields)) => { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + 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.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) } +/// 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). const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ "file.openRecent1", @@ -1500,16 +1514,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Envelope Distort", vec![ - cp( - "Make with Warp…", - "object.envelope.makeWithWarp", - json!({"style": "arc", "bend": 50, "h": 0, "v": 0, "horizontal": true}), - ), - cp("Make with Mesh…", "object.envelope.makeWithMesh", json!({"rows": 4, "cols": 4})), + // While an envelope is selected, Reset takes Make's place (`swapped_out`). + c("Make with Warp…", "object.envelope.makeWithWarp"), + c("Reset with Warp…", "object.envelope.resetWithWarp"), + c("Make with Mesh…", "object.envelope.makeWithMesh"), + c("Reset with Mesh…", "object.envelope.resetWithMesh"), c("Make with Top Object", "object.envelope.makeWithTopObject"), Sep, 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"), Sep, 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) => { let en = enabled(app, id); // 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; } 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)); 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. if id == "object.repeat.options" && p.as_object().is_none_or(|o| o.is_empty()) @@ -2146,7 +2166,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec { fn walk(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { 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 { path: path.clone(), label: dynamic_label(app, id, l), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d2b52995..cc733e6f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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`; lengths in `units`, so setting `units` converts them). `ui.dialog.confirm` runs `perspective.grid.define`; a refused 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. diff --git a/docs/mcp.md b/docs/mcp.md index 07a887f9..dedf38a1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.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":{}}} +```