M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options
Make with Warp and Make with Mesh open dedicated dialogs (style dropdown, orientation, bend and distortion sliders; rows and columns) with live preview; while an envelope is selected the menu shows Reset with Warp / Reset with Mesh instead, which switch the envelope's kind and keep its content (Maintain Envelope Shape samples the current surface). Envelope Options is a full dialog prefilled from the selection (Anti-Alias, Preserve Shape, Fidelity, Distort Appearance, Distort Linear Gradients, Distort Pattern Fills); with nothing selected it sets the defaults for new envelopes. object.envelope.info reads the settings. Release returns the warp or mesh shape as a gradient mesh and keeps opacity, blend mode and mask; Expand keeps name, knockout, isolation and mask; Object > Expand expands envelopes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6994f248b2
commit
fe6d6640fb
|
|
@ -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<Self> {
|
||||
match s {
|
||||
"clippingMask" => Some(Self::ClippingMask),
|
||||
"transparency" => Some(Self::Transparency),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options
|
||||
/// existed do (their appearance applies after the distortion); new envelopes start from
|
||||
/// [`EnvelopeOptions::NEW`], as in the reference app.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct EnvelopeOptions {
|
||||
/// Rasters: Anti-Alias.
|
||||
pub anti_alias: bool,
|
||||
/// Rasters: Preserve Shape Using.
|
||||
pub preserve_shape: PreserveShape,
|
||||
/// Distort Appearance: strokes and live effects are applied before the distortion, so they
|
||||
/// bend with the art (off: they apply to the distorted paths).
|
||||
pub distort_appearance: bool,
|
||||
/// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art.
|
||||
pub distort_linear_gradients: bool,
|
||||
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
|
||||
pub distort_pattern_fills: bool,
|
||||
}
|
||||
|
||||
impl Default for EnvelopeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
anti_alias: true,
|
||||
preserve_shape: PreserveShape::ClippingMask,
|
||||
distort_appearance: false,
|
||||
distort_linear_gradients: false,
|
||||
distort_pattern_fills: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EnvelopeOptions {
|
||||
/// The options a new envelope gets (the reference app's defaults).
|
||||
pub const NEW: Self = Self {
|
||||
anti_alias: true,
|
||||
preserve_shape: PreserveShape::ClippingMask,
|
||||
distort_appearance: true,
|
||||
distort_linear_gradients: false,
|
||||
distort_pattern_fills: false,
|
||||
};
|
||||
/// Do gradients bend with the art?
|
||||
pub fn gradients(&self) -> bool {
|
||||
self.distort_appearance && self.distort_linear_gradients
|
||||
}
|
||||
/// Do pattern tiles bend with the art?
|
||||
pub fn patterns(&self) -> bool {
|
||||
self.distort_appearance && self.distort_pattern_fills
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
|
||||
pub const H_RIGHT: usize = 0;
|
||||
pub const H_LEFT: usize = 1;
|
||||
|
|
@ -1392,6 +1467,16 @@ pub fn envelope_expand(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64
|
|||
content.iter().map(|c| map_node(c, &*f, piece, outline)).collect()
|
||||
}
|
||||
|
||||
/// The surface an envelope maps `content` onto, sampled on a `rows`×`cols` grid as a mesh whose
|
||||
/// handles follow the surface and whose points are `color`: Release gives it back as the
|
||||
/// envelope's shape and Reset with Mesh starts from it. `None` when the content has no bounds.
|
||||
pub fn envelope_surface(content: &[Arc<Node>], kind: &EnvelopeKind, rows: u32, cols: u32, color: Color) -> Option<GradientMesh> {
|
||||
let src = nodes_bounds(content)?;
|
||||
let f = envelope_mapper(kind, src);
|
||||
let at = |u: f64, v: f64| f(Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()));
|
||||
Some(GradientMesh::from_surface(rows, cols, &at, &|_, _| color))
|
||||
}
|
||||
|
||||
/// Envelope bounds (the image of the content box, sampled).
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Rect> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<Value> {
|
|||
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, _| {
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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<Envelop
|
|||
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
|
||||
}
|
||||
|
||||
fn envelope(id: NodeId, content: Vec<Arc<Node>>, 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<Arc<Node>>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node {
|
||||
Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options })
|
||||
}
|
||||
|
||||
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
|
||||
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
|
||||
let rows = f64_or(p, "rows", default.0 as f64);
|
||||
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
|
||||
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
|
||||
return Err(bad(cmd, "rows and cols must be 1..50"));
|
||||
}
|
||||
Ok((rows as u32, cols as u32))
|
||||
}
|
||||
|
||||
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -536,26 +580,30 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Node>], shape: Option<&EnvelopeKind>, rows: u32, cols: u32) -> Option<EnvelopeKind> {
|
||||
let points = match shape {
|
||||
Some(k) => live::envelope_surface(content, k, rows, cols, Color::BLACK)?.points.into_iter().map(|m| m.p).collect(),
|
||||
None => live::grid_points(live::nodes_bounds(content)?, rows, cols),
|
||||
};
|
||||
Some(EnvelopeKind::Mesh { rows, cols, points })
|
||||
}
|
||||
|
||||
fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Arc<Node>> = 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<Value> {
|
|||
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<Node>], &EnvelopeKind) -> Result<EnvelopeKind>) -> Result<Vec<(NodeId, EnvelopeKind)>> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
for e in selected_of(s, is_envelope) {
|
||||
if let Some(NodeKind::Envelope { content, kind, .. }) = doc.node(e).map(|n| &n.kind) {
|
||||
out.push((e, f(content, kind)?));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Give each envelope of `kinds` its new kind (as one undo step `label`).
|
||||
fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) -> Result<Value> {
|
||||
s.edit(label, |d, _| {
|
||||
for (e, k) in kinds {
|
||||
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
|
||||
*kind = k;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.resetWithWarp";
|
||||
let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?;
|
||||
set_kinds(s, "Reset with Warp", kinds)
|
||||
}
|
||||
|
||||
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.resetWithMesh";
|
||||
let maintain = bool_or(p, "maintainShape", true);
|
||||
let kinds = reshaped(s, |content, kind| {
|
||||
let own = match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
|
||||
_ => (4, 4),
|
||||
};
|
||||
let (rows, cols) = rows_cols(p, C, own)?;
|
||||
mesh_kind(content, maintain.then_some(kind), rows, cols).ok_or_else(|| bad(C, "the envelope's content has no bounds"))
|
||||
})?;
|
||||
set_kinds(s, "Reset with Mesh", kinds)
|
||||
}
|
||||
|
||||
/// `v` with an envelope's options and fidelity added.
|
||||
fn options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value {
|
||||
v["fidelity"] = json!(fidelity);
|
||||
v["antiAlias"] = json!(o.anti_alias);
|
||||
v["preserveShape"] = json!(o.preserve_shape.id());
|
||||
v["distortAppearance"] = json!(o.distort_appearance);
|
||||
v["distortLinearGradients"] = json!(o.distort_linear_gradients);
|
||||
v["distortPatternFills"] = json!(o.distort_pattern_fills);
|
||||
v
|
||||
}
|
||||
|
||||
/// `cur` with the options `p` sets.
|
||||
fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeOptions> {
|
||||
let preserve_shape = match str_param(p, "preserveShape") {
|
||||
Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?,
|
||||
None => cur.preserve_shape,
|
||||
};
|
||||
Ok(EnvelopeOptions {
|
||||
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
|
||||
preserve_shape,
|
||||
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
|
||||
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
|
||||
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
|
||||
})
|
||||
}
|
||||
|
||||
fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind)));
|
||||
let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else {
|
||||
let (options, fidelity) = envelope_defaults(s);
|
||||
return Ok(options_json(json!({ "id": null }), &options, fidelity));
|
||||
};
|
||||
let v = match kind {
|
||||
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
|
||||
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
|
||||
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
|
||||
};
|
||||
let mut v = options_json(v, options, *fidelity);
|
||||
v["id"] = json!(id.0);
|
||||
v["editing"] = json!(editing);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// What Release gives back as an envelope's shape: a top object's path, or the surface of a warp
|
||||
/// or mesh envelope as a gradient mesh, painted grey (id 0).
|
||||
fn envelope_shape(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Node> {
|
||||
let grey = Color::gray(0.25);
|
||||
let (rows, cols) = match kind {
|
||||
EnvelopeKind::TopObject { path } => {
|
||||
return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0)));
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
|
||||
EnvelopeKind::Warp { .. } => (4, 4),
|
||||
};
|
||||
live::envelope_surface(content, kind, rows, cols, grey).map(|m| Node::new(NodeId(0), NodeKind::Mesh(m)))
|
||||
}
|
||||
|
||||
/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity
|
||||
/// mask: a single object without its own takes them, else a new group around the content does.
|
||||
fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec<Node>) -> Vec<Node> {
|
||||
if env.has_default_transparency() {
|
||||
return nodes;
|
||||
}
|
||||
let carry = |n: &mut Node| {
|
||||
n.opacity = env.opacity;
|
||||
n.blend = env.blend;
|
||||
n.isolate = env.isolate;
|
||||
n.knockout = env.knockout;
|
||||
n.knockout_shape = env.knockout_shape;
|
||||
n.mask = env.mask.clone();
|
||||
};
|
||||
if let [one] = nodes.as_mut_slice()
|
||||
&& one.has_default_transparency()
|
||||
{
|
||||
carry(one);
|
||||
return nodes;
|
||||
}
|
||||
let mut g = Node::group(d.alloc_id(), nodes.into_iter().map(Arc::new).collect());
|
||||
carry(&mut g);
|
||||
vec![g]
|
||||
}
|
||||
|
||||
fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
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<Node> = 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<Value> {
|
|||
|
||||
fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// Replace envelope `id` by a group of its distorted content (type outlined) with its id, name,
|
||||
/// transparency and opacity mask; the generated pieces get ids of their own.
|
||||
fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &vectorcraft_render::effects::outline_text;
|
||||
let mut g = live::expanded_group(&n, Some(hook));
|
||||
fix_ids(d, &mut g);
|
||||
// Outlined type repeats its object's id on its pieces.
|
||||
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
|
||||
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Object → Expand: every envelope in `root`'s subtree (itself included) expanded
|
||||
/// ([`expand_envelope`]; envelopes inside envelopes go with them). Returns how many.
|
||||
pub(super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result<usize> {
|
||||
fn collect(n: &Node, out: &mut Vec<NodeId>) {
|
||||
if is_envelope(n) {
|
||||
out.push(n.id);
|
||||
} else {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
collect(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut envs = vec![];
|
||||
if let Some(n) = d.node(root) {
|
||||
collect(n, &mut envs);
|
||||
}
|
||||
for e in &envs {
|
||||
expand_envelope(d, *e)?;
|
||||
}
|
||||
Ok(envs.len())
|
||||
}
|
||||
|
||||
fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
|
||||
fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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![];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
//! Envelope Distort: Reset with Warp / Mesh, Envelope Options, Release, Expand and Object → Expand,
|
||||
//! driven through `Session::execute`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::live::{self, EnvelopeKind, EnvelopeOptions, PreserveShape};
|
||||
use vectorcraft_doc::{Knockout, Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// A 200×100 rectangle at (100, 100) enveloped by `cmd` with `params` → (rectangle, envelope).
|
||||
fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
|
||||
let a = rect(s, 100.0, 100.0, 200.0, 100.0);
|
||||
sel(s, &[a]);
|
||||
(a, id_of(&s.execute(cmd, ¶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<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert_eq!(ids[0], a);
|
||||
let content = node(&s, a);
|
||||
assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
|
||||
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
|
||||
// The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
|
||||
let b = m.outline().bounds().unwrap();
|
||||
assert!(b.y1 > 201.0, "{b:?}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_groups_several_objects_under_the_transparency() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 2})).unwrap());
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let r = s.execute("object.envelope.release", &json!({})).unwrap();
|
||||
let g = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert_eq!(g.opacity, 0.5);
|
||||
assert_eq!(g.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), vec![a, b]);
|
||||
assert!(matches!(node(&s, NodeId(r["ids"][1].as_u64().unwrap())).kind, NodeKind::Mesh(ref m) if (m.rows, m.cols) == (1, 2)));
|
||||
assert!(s.doc().unwrap().doc.node(e).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_keeps_name_transparency_knockout_and_mask() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "bulge", "bend": 40}));
|
||||
s.execute("transparency.set", &json!({"opacity": 70, "isolate": true, "knockout": "on"})).unwrap();
|
||||
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
|
||||
s.edit("name and mask", |d, _| {
|
||||
let n = d.node_mut(e).unwrap();
|
||||
n.name = Some("Banner".into());
|
||||
n.mask = Some(Box::new(OpacityMask::new(mask, true)));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.envelope.expand", &json!({})).unwrap();
|
||||
let g = node(&s, e);
|
||||
assert!(matches!(g.kind, NodeKind::Group { .. }));
|
||||
assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_expand_expands_envelopes_inside_the_selection() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50}));
|
||||
let other = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
sel(&mut s, &[e, other]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!(info["object"], json!(true));
|
||||
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
|
||||
let mut envelopes = 0;
|
||||
node(&s, g).walk(&mut |n| envelopes += matches!(n.kind, NodeKind::Envelope { .. }) as usize);
|
||||
assert_eq!(envelopes, 0);
|
||||
// The arch lifted the expanded art's middle above the rectangle's top edge.
|
||||
let b = node(&s, e).geometric_bounds().unwrap();
|
||||
assert!(b.y0 < 99.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_options_round_trip_natively_and_old_files_load() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({}));
|
||||
s.execute("object.envelope.options", &json!({"preserveShape": "transparency", "distortPatternFills": true})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
assert_eq!(back.node(e), d.node(e));
|
||||
// An envelope saved before the options existed loads with its old behaviour.
|
||||
let mut old = serde_json::to_value(node(&s, e)).unwrap();
|
||||
old["kind"].as_object_mut().unwrap().remove("options");
|
||||
let n: Node = serde_json::from_value(old).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::default() && !options.distort_appearance));
|
||||
// The old behaviour's options aren't written.
|
||||
let mut legacy = node(&s, e);
|
||||
if let NodeKind::Envelope { options, .. } = &mut legacy.kind {
|
||||
*options = EnvelopeOptions::default();
|
||||
}
|
||||
assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none());
|
||||
}
|
||||
|
|
@ -363,3 +363,47 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope Distort's commands on warp, mesh and top-object envelopes (selected, and with their
|
||||
/// content selected in Edit Contents) with `{}` and junk params: never a panic, an open
|
||||
/// interaction or a broken tree.
|
||||
#[test]
|
||||
fn envelope_commands_with_junk_params_on_envelopes() {
|
||||
let path = safe_path();
|
||||
let makes: [(&str, Value); 3] = [
|
||||
("object.envelope.makeWithWarp", json!({"style": "twist", "bend": 80})),
|
||||
("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})),
|
||||
("object.envelope.makeWithTopObject", json!({})),
|
||||
];
|
||||
let mut failures = vec![];
|
||||
for (make, mp) in &makes {
|
||||
for editing in [false, true] {
|
||||
let ids: Vec<&str> =
|
||||
command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect();
|
||||
for id in ids {
|
||||
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
|
||||
let mut s = Fixture::Multi.session();
|
||||
if s.execute(make, mp).is_err() {
|
||||
failures.push(format!("{make} failed on the Multi fixture"));
|
||||
continue;
|
||||
}
|
||||
if editing {
|
||||
let _ = s.execute("object.envelope.editContents", &json!({"editing": true}));
|
||||
}
|
||||
let r = catch_quiet(|| s.execute(id, &p));
|
||||
if r.is_err() {
|
||||
failures.push(format!("PANIC {id} {p} [{make}, editing {editing}]"));
|
||||
} else if s.in_interaction() {
|
||||
failures.push(format!("{id} {p} [{make}]: left an interaction open"));
|
||||
} else if let Err(e) = check_session(&s) {
|
||||
failures.push(format!("{id} {p} [{make}]: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures.sort();
|
||||
failures.dedup();
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
//! Object → Envelope Distort's dialogs, previewed live on the canvas; OK keeps the preview as one
|
||||
//! undo step:
|
||||
//!
|
||||
//! - **Warp Options** (`envelopeWarp`): Make with Warp, or Reset with Warp for a selected envelope
|
||||
//! (`reset`). Fields `style` (one of the 15 warp styles), `horizontal` (false: Vertical),
|
||||
//! `bend`, `h` and `v` (%, -100..100).
|
||||
//! - **Envelope Mesh** (`envelopeMesh`): Make with Mesh, or Reset with Mesh (`reset`, with
|
||||
//! `maintainShape`). Fields `rows`, `cols` (1..50).
|
||||
//! - **Envelope Options** (`envelopeOptions`): the selected envelopes' options, or with none
|
||||
//! selected the options new envelopes get (no preview then). Fields `antiAlias`,
|
||||
//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100),
|
||||
//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`.
|
||||
//!
|
||||
//! Every dialog also has `preview`. They open with the selected envelope's values
|
||||
//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh
|
||||
//! open the Reset variant while an envelope is selected, as the reference app's do.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_render::effects::WARP_STYLES;
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kinds.
|
||||
pub const WARP: &str = "envelopeWarp";
|
||||
pub const MESH: &str = "envelopeMesh";
|
||||
pub const OPTIONS: &str = "envelopeOptions";
|
||||
|
||||
const MAKE_WARP: &str = "object.envelope.makeWithWarp";
|
||||
const RESET_WARP: &str = "object.envelope.resetWithWarp";
|
||||
const MAKE_MESH: &str = "object.envelope.makeWithMesh";
|
||||
const RESET_MESH: &str = "object.envelope.resetWithMesh";
|
||||
const OPTIONS_CMD: &str = "object.envelope.options";
|
||||
|
||||
/// Width of the label column.
|
||||
const LABEL_W: f32 = 84.0;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
match d.kind.as_str() {
|
||||
WARP => "Warp Options",
|
||||
MESH if d.bool("reset") => "Reset Envelope Mesh",
|
||||
MESH => "Envelope Mesh",
|
||||
_ => "Envelope Options",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Does the menu item of command `id` open one of these dialogs?
|
||||
pub fn opens(id: &str) -> bool {
|
||||
matches!(id, MAKE_WARP | RESET_WARP | MAKE_MESH | RESET_MESH | OPTIONS_CMD)
|
||||
}
|
||||
|
||||
/// Open the dialog command `id`'s menu item opens, with the selected envelope's values.
|
||||
pub fn open(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
|
||||
let info = app.session.execute("object.envelope.info", &json!({})).map_err(|e| e.to_string())?;
|
||||
let envelope = !info["id"].is_null();
|
||||
if matches!(id, RESET_WARP | RESET_MESH) && !envelope {
|
||||
return Err("select an envelope".into());
|
||||
}
|
||||
let num = |k: &str, d: f64| info[k].as_f64().unwrap_or(d);
|
||||
let (kind, fields) = match id {
|
||||
MAKE_WARP | RESET_WARP => {
|
||||
let warp = info["type"] == "warp";
|
||||
let own = |k: &str, d: f64| if warp { num(k, d) } else { d };
|
||||
let style = if warp { info["style"].clone() } else { json!("arc") };
|
||||
let horizontal = !warp || info["horizontal"] != false;
|
||||
(
|
||||
WARP,
|
||||
json!({"reset": envelope, "style": style, "horizontal": horizontal, "bend": own("bend", 50.0), "h": own("h", 0.0), "v": own("v", 0.0), "preview": true}),
|
||||
)
|
||||
}
|
||||
MAKE_MESH | RESET_MESH => {
|
||||
let mesh = info["type"] == "mesh";
|
||||
let own = |k: &str| if mesh { num(k, 4.0) } else { 4.0 };
|
||||
(MESH, json!({"reset": envelope, "rows": own("rows"), "cols": own("cols"), "maintainShape": true, "preview": true}))
|
||||
}
|
||||
OPTIONS_CMD => {
|
||||
let mut f = json!({"__envelope": envelope, "preview": envelope});
|
||||
for k in ["antiAlias", "preserveShape", "fidelity", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
|
||||
f[k] = info[k].clone();
|
||||
}
|
||||
(OPTIONS, f)
|
||||
}
|
||||
_ => return Err(format!("`{id}` has no envelope dialog")),
|
||||
};
|
||||
app.ui.dialog = Some(Dialog::new(kind, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The command OK runs, its parameters and the preview's undo label.
|
||||
fn command(d: &Dialog) -> (&'static str, Value, &'static str) {
|
||||
let reset = d.bool("reset");
|
||||
match d.kind.as_str() {
|
||||
WARP => {
|
||||
let p = json!({"style": d.str("style"), "horizontal": d.bool("horizontal"), "bend": d.f64("bend", 50.0), "h": d.f64("h", 0.0), "v": d.f64("v", 0.0)});
|
||||
if reset { (RESET_WARP, p, "Reset with Warp") } else { (MAKE_WARP, p, "Make Envelope") }
|
||||
}
|
||||
MESH => {
|
||||
let p = json!({"rows": d.f64("rows", 4.0), "cols": d.f64("cols", 4.0)});
|
||||
if reset {
|
||||
let mut p = p;
|
||||
p["maintainShape"] = json!(d.bool("maintainShape"));
|
||||
(RESET_MESH, p, "Reset with Mesh")
|
||||
} else {
|
||||
(MAKE_MESH, p, "Make Envelope")
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut p = json!({"fidelity": d.f64("fidelity", 50.0), "preserveShape": d.str("preserveShape")});
|
||||
for k in ["antiAlias", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
|
||||
p[k] = json!(d.bool(k));
|
||||
}
|
||||
(OPTIONS_CMD, p, "Envelope Options")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set(d: &mut Dialog, key: &str, v: Value) {
|
||||
d.fields.insert(key.into(), v);
|
||||
}
|
||||
|
||||
/// A −100..100 % slider row.
|
||||
fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail);
|
||||
}
|
||||
|
||||
/// A whole number field row clamped to 1..50.
|
||||
fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
widgets::label_row(ui, label, LABEL_W, |ui| {
|
||||
if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) {
|
||||
set(d, key, json!(n.round().clamp(1.0, 50.0)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
|
||||
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
|
||||
let horizontal = d.bool("horizontal");
|
||||
widgets::label_row(ui, "", LABEL_W, |ui| {
|
||||
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
|
||||
if widgets::radio(ui, label, horizontal == h, true) {
|
||||
set(d, "horizontal", json!(h));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
percent(ui, d, "bend", "Bend:");
|
||||
ui.add_space(6.0);
|
||||
widgets::subheader(ui, "Distortion");
|
||||
percent(ui, d, "h", "Horizontal:");
|
||||
percent(ui, d, "v", "Vertical:");
|
||||
}
|
||||
|
||||
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
count(ui, d, "rows", "Rows:");
|
||||
count(ui, d, "cols", "Columns:");
|
||||
if d.bool("reset") {
|
||||
ui.add_space(4.0);
|
||||
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
|
||||
}
|
||||
}
|
||||
|
||||
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::subheader(ui, "Rasters");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
form::check(ui, d, "antiAlias", "Anti-Alias");
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::subheader(ui, "Preserve Shape Using:");
|
||||
let clip = d.str("preserveShape") != "transparency";
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
|
||||
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
|
||||
set(d, "preserveShape", json!(value));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
|
||||
ui.add_space(4.0);
|
||||
form::check(ui, d, "distortAppearance", "Distort Appearance");
|
||||
// Gradients and patterns bend only with the appearance.
|
||||
let appearance = d.bool("distortAppearance");
|
||||
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
|
||||
let on = d.bool(key);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(20.0);
|
||||
if widgets::check(ui, label, on, appearance) {
|
||||
set(d, key, json!(!on));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
match d.kind.as_str() {
|
||||
WARP => warp_body(ui, d),
|
||||
MESH => mesh_body(ui, d),
|
||||
_ => options_body(ui, d),
|
||||
}
|
||||
// With no envelope selected, Envelope Options sets the defaults: nothing to preview.
|
||||
if d.kind != OPTIONS || d.bool("__envelope") {
|
||||
let (cmd, p, label) = command(d);
|
||||
form::preview(app, ui, d, label, cmd, p);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (cmd, p, _) = command(d);
|
||||
form::commit_preview(app, cmd, p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::live::EnvelopeKind;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn app_with_rect() -> (VectorcraftApp, NodeId) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
(app, NodeId(id))
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn envelope(app: &VectorcraftApp) -> (NodeId, vectorcraft_doc::Node) {
|
||||
let st = app.session.doc().unwrap();
|
||||
let id = st.selection.objects[0];
|
||||
(id, st.doc.node(id).unwrap().clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_dialog_previews_and_ok_keeps_one_step() {
|
||||
let (mut app, rect) = app_with_rect();
|
||||
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.fields["reset"].clone())), Some((WARP, json!(false))));
|
||||
frame(&mut app);
|
||||
// The preview made the envelope already.
|
||||
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { .. }));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("style".into(), json!("flag"));
|
||||
d.fields.insert("horizontal".into(), json!(false));
|
||||
d.fields.insert("bend".into(), json!(-30));
|
||||
frame(&mut app);
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let (_, n) = envelope(&app);
|
||||
match &n.kind {
|
||||
NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => {
|
||||
assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false));
|
||||
assert_eq!(content[0].id, rect);
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
// With the envelope selected, the same item resets it, starting from its values.
|
||||
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0)));
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_items_take_the_make_items_place_while_an_envelope_is_selected() {
|
||||
let labels = |app: &VectorcraftApp| -> Vec<String> {
|
||||
crate::menus::menu_entries(app)
|
||||
.into_iter()
|
||||
.filter(|e| e.path.last().map(String::as_str) == Some("Envelope Distort"))
|
||||
.map(|e| e.label)
|
||||
.collect()
|
||||
};
|
||||
let (mut app, _) = app_with_rect();
|
||||
let before = labels(&app);
|
||||
assert!(before.contains(&"Make with Warp…".to_string()) && !before.contains(&"Reset with Warp…".to_string()), "{before:?}");
|
||||
app.run(MAKE_MESH, json!({})).unwrap();
|
||||
let after = labels(&app);
|
||||
assert!(after.contains(&"Reset with Warp…".to_string()) && after.contains(&"Reset with Mesh…".to_string()), "{after:?}");
|
||||
assert!(!after.contains(&"Make with Warp…".to_string()) && !after.contains(&"Make with Mesh…".to_string()), "{after:?}");
|
||||
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_ok());
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_err(), "nothing to reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_mesh_dialog_switches_the_kind_and_cancel_rolls_back() {
|
||||
let (mut app, _) = app_with_rect();
|
||||
app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap();
|
||||
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH));
|
||||
frame(&mut app);
|
||||
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed");
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }));
|
||||
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_dialog_starts_from_the_envelope_and_without_one_sets_the_defaults() {
|
||||
let (mut app, _) = app_with_rect();
|
||||
app.run(MAKE_WARP, json!({})).unwrap();
|
||||
app.run("object.envelope.options", json!({"fidelity": 80, "distortPatternFills": true})).unwrap();
|
||||
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.fields["fidelity"].clone(), d.fields["distortPatternFills"].clone()), (json!(80.0), json!(true)));
|
||||
d.fields.insert("preserveShape".into(), json!("transparency"));
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let info = app.session.execute("object.envelope.info", &json!({})).unwrap();
|
||||
assert_eq!((info["preserveShape"].clone(), info["fidelity"].clone()), (json!("transparency"), json!(80.0)));
|
||||
// Nothing selected: the defaults for new envelopes, no preview.
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.fields["preview"], json!(false));
|
||||
d.fields.insert("distortAppearance".into(), json!(false));
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction());
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false));
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
"ui.expandDialog" => 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<Item>)> {
|
|||
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<MenuEntry> {
|
|||
fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
28
docs/mcp.md
28
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":{}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue