diff --git a/ROADMAP.md b/ROADMAP.md index b14d0f83..341460fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours) | 8–12 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options) | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 | 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":{}}} +```