diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 6851205a..ed2e68b7 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -46,6 +46,11 @@ impl Dash { pub fn is_dashed(&self) -> bool { self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) } + /// Scale the dash and gap lengths and the offset by `s`. + pub fn scale(&mut self, s: f64) { + self.pattern.iter_mut().for_each(|v| *v *= s); + self.offset *= s; + } } /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. @@ -712,19 +717,25 @@ impl Appearance { pub fn outset(&self) -> f64 { self.painted_strokes().map(StrokeLayer::reach).fold(0.0, f64::max) } - /// Scale stroke weights (Scale Strokes & Effects). + /// Scale stroke weights, dash lengths and dash offsets (Scale Strokes & Effects). pub fn scale_strokes(&mut self, s: f64) { for i in &mut self.items { if let AppearanceItem::Stroke(st) = i { st.width *= s; if let Some(d) = &mut st.dash { - for v in &mut d.pattern { - *v *= s; - } + d.scale(s); } } } } + /// Scale the distance parameters of every effect (the object's and each fill's and stroke's) + /// by `s` with `scale` (the effects catalogue knows which parameters are distances). + pub fn scale_effects(&mut self, s: f64, scale: fn(&mut Effect, f64)) { + let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in self.effects.iter_mut().chain(items) { + scale(e, s); + } + } /// Gradient paints of the fills and strokes. fn gradients_mut(&mut self) -> impl Iterator { self.items.iter_mut().filter_map(|i| match i { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 880ba9eb..8682cb2e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -44,6 +44,7 @@ pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use node::Knockout; +pub use node::Scaling; pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index e536bbf3..2fbdd11c 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -138,6 +138,17 @@ impl LiveShape { } } } + /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners + /// keep their size (Scale Corners off). False when nothing changed. + pub fn keep_corners(&mut self, k: f64) -> bool { + match self { + LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) && k > 1e-12 => { + radii.iter_mut().for_each(|r| *r /= k); + true + } + _ => false, + } + } /// Rotation angle of the live shape in degrees (shown in the Properties panel). pub fn angle_deg(&self) -> f64 { match self { @@ -150,6 +161,36 @@ impl LiveShape { } } +/// What a transform scales besides geometry, by its mean scale (the square root of its +/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke +/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects +/// and Scale Corners. +#[derive(Clone, Copy, Debug, Default)] +pub struct Scaling { + /// Stroke weights, dash lengths and dash offsets scale. + pub strokes: bool, + /// With `strokes`: scales an effect's distance parameters (the effects catalogue's). + pub effects: Option, + /// Type keeps its character strokes' weight (they otherwise scale with the type). + pub keep_type_strokes: bool, + /// Live corner radii keep their size. + pub keep_corners: bool, +} + +impl Scaling { + /// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by. + pub fn factor(a: Affine) -> Option { + let k = a.determinant().abs().sqrt(); + ((k - 1.0).abs() > 1e-9).then_some(k) + } +} + +impl From for Scaling { + fn from(strokes: bool) -> Self { + Self { strokes, ..Self::default() } + } +} + /// Embedded or linked raster image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ImageObject { @@ -515,16 +556,23 @@ impl Node { } } /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. - /// `scale_strokes` also scales stroke weights by the transform's mean scale. - pub fn transform(&mut self, a: Affine, scale_strokes: bool) { + /// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights + /// only, see [`Scaling`]). + pub fn transform(&mut self, a: Affine, scaling: impl Into) { + self.transform_scaled(a, &scaling.into()); + } + fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { // Refitting an unplaced gradient only reproduces moves and uniform scales. if !keeps_gradient_fit(a) { self.pin_gradients(); } - if scale_strokes { - let det = a.determinant().abs().sqrt(); - if (det - 1.0).abs() > 1e-9 { - self.appearance.scale_strokes(det); + let k = Scaling::factor(a); + if let Some(k) = k + && sc.strokes + { + self.appearance.scale_strokes(k); + if let Some(f) = sc.effects { + self.appearance.scale_effects(k, f); } } self.appearance.transform_gradients(a); @@ -533,19 +581,33 @@ impl Node { path.transform(a); if let Some(l) = live { l.transform(a); + if sc.keep_corners + && let Some(k) = k + && l.keep_corners(k) + { + *path = l.to_path(); + } } } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { for c in children.iter_mut() { - Arc::make_mut(c).transform(a, scale_strokes); + Arc::make_mut(c).transform_scaled(a, sc); + } + } + NodeKind::Text(t) => { + t.transform(a); + // Character strokes scale with the type: undo that when strokes keep their weight. + if sc.keep_type_strokes + && let Some(k) = k.filter(|k| *k > 1e-12) + { + t.scale_char_strokes(1.0 / k); } } - NodeKind::Text(t) => t.transform(a), NodeKind::Image(im) => im.xf = a * im.xf, NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, NodeKind::Blend { children, spec } => { for c in children.iter_mut() { - Arc::make_mut(c).transform(a, scale_strokes); + Arc::make_mut(c).transform_scaled(a, sc); } if let Some(s) = &mut spec.spine { s.transform(a); @@ -553,7 +615,7 @@ impl Node { } NodeKind::Envelope { content, kind, .. } => { for c in content.iter_mut() { - Arc::make_mut(c).transform(a, scale_strokes); + Arc::make_mut(c).transform_scaled(a, sc); } match kind { EnvelopeKind::Mesh { points, .. } => { @@ -566,12 +628,12 @@ impl Node { } } NodeKind::Mesh(m) => m.transform(a), - NodeKind::Repeat(r) => r.transform(a, scale_strokes), + NodeKind::Repeat(r) => r.transform(a, *sc), } if let Some(m) = &mut self.mask && m.linked { - Arc::make_mut(&mut m.art).transform(a, scale_strokes); + Arc::make_mut(&mut m.art).transform_scaled(a, sc); } } /// Visit this node and all descendants depth first (paint order). diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index cc89787e..aa185815 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -503,9 +503,10 @@ impl RepeatSpec { self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b)))) } /// Apply a document transform (source + arrangement parameters). - pub fn transform(&mut self, a: Affine, scale_strokes: bool) { + pub fn transform(&mut self, a: Affine, scaling: impl Into) { + let scaling = scaling.into(); for c in self.source.iter_mut() { - Arc::make_mut(c).transform(a, scale_strokes); + Arc::make_mut(c).transform(a, scaling); } let s = a.determinant().abs().sqrt(); match &mut self.kind { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 077d00e4..010c2647 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -414,6 +414,16 @@ impl TextObject { pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } + /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so + /// `1 / scale` keeps their weight through a transform that scales the type). + pub fn scale_char_strokes(&mut self, s: f64) { + for r in &mut self.runs { + r.style.stroke_width *= s; + if let Some(d) = &mut r.style.stroke_dash { + d.scale(s); + } + } + } /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run /// gradient fits, as the renderers lay it out. pub fn local_bounds(&self) -> Rect { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index eb06ce18..e0e65e06 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -37,6 +37,8 @@ mod warp; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_scale; use serde_json::{Map, Value, json}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; @@ -67,6 +69,37 @@ pub struct EffectInfo { pub defaults: Value, /// Raster (painted) rather than geometry effect. pub raster: bool, + /// The parameters that are distances in points (Scale Strokes & Effects scales them). + pub lengths: Lengths, +} + +/// Which of an effect's parameters are distances in points. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Lengths { + /// Always distances. + pub always: &'static [&'static str], + /// Distances while the effect's `relative` parameter is false (percentages of the object's + /// size otherwise). + pub absolute: &'static [&'static str], +} + +/// The distance parameters of effect `id` (none for unknown effects). +fn lengths_of(id: &str) -> Lengths { + let always = |always| Lengths { always, absolute: &[] }; + match id { + "convertToShape.rectangle" | "convertToShape.ellipse" => always(&["extraW", "extraH", "width", "height"]), + "convertToShape.roundedRectangle" => always(&["extraW", "extraH", "width", "height", "radius"]), + "distort.roughen" | "distort.zigZag" => Lengths { always: &[], absolute: &["size"] }, + "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, + "distort.transform" => always(&["moveH", "moveV"]), + "path.offsetPath" => always(&["offset"]), + "path.outlineStroke" => always(&["width"]), + "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]), + "stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]), + "stylize.dropShadow" => always(&["x", "y", "blur"]), + "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), + _ => Lengths::default(), + } } const WARP_DOC: &str = @@ -100,8 +133,8 @@ pub const WARP_STYLES: [(&str, &str); 15] = [ /// Every supported effect with its dialog defaults. pub fn effect_catalog() -> Vec { - let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false }; - let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true }; + let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; + let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; let mut v = vec![ g( "convertToShape.rectangle", @@ -276,6 +309,25 @@ pub fn new_effect(id: &str, params: &Value) -> Option { Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) } +/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see +/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the +/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent. +pub fn scale_effect(e: &mut Effect, s: f64) { + let Some(info) = catalog_index().get(e.id.as_str()) else { return }; + let merged = merged_params(&e.id, &e.params); + let relative = util::flag(&merged, "relative", false); + let keys = info.lengths.always.iter().chain(if relative { &[][..] } else { info.lengths.absolute }); + for k in keys { + let v = util::num(&merged, k, f64::NAN); + if v.is_finite() { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params[*k] = json!(v * s); + } + } +} + /// Is `id` a raster (painted) effect? pub fn is_raster(id: &str) -> bool { matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") diff --git a/crates/effects/src/tests_scale.rs b/crates/effects/src/tests_scale.rs new file mode 100644 index 00000000..420a273a --- /dev/null +++ b/crates/effects/src/tests_scale.rs @@ -0,0 +1,34 @@ +//! Scale Strokes & Effects on effects: the catalogue's distance parameters and `scale_effect`. + +use serde_json::json; +use vectorcraft_doc::Effect; + +use super::*; + +#[test] +fn every_distance_parameter_is_a_parameter() { + for e in effect_catalog() { + for k in e.lengths.always.iter().chain(e.lengths.absolute) { + assert!(e.defaults.get(*k).is_some() || e.params.contains(&format!("{k}?:")), "{}: `{k}` is not a parameter", e.id); + } + if !e.lengths.absolute.is_empty() { + assert!(e.defaults.get("relative").is_some(), "{}: absolute lengths need a `relative` param", e.id); + } + } +} + +#[test] +fn scale_effect_scales_distances_only() { + let mut e = new_effect("stylize.dropShadow", &json!({"x": 3, "y": "4 pt"})).unwrap(); + scale_effect(&mut e, 2.0); + assert_eq!((&e.params["x"], &e.params["y"], &e.params["blur"]), (&json!(6.0), &json!(8.0), &json!(10.0))); + assert_eq!(e.params["opacity"], json!(75.0)); + // Outline Stroke without a weight follows its stroke's: nothing to scale. + let mut o = Effect { id: "path.outlineStroke".into(), params: json!(null), visible: true }; + scale_effect(&mut o, 2.0); + assert_eq!(o.params, json!(null)); + // Relative tweaks are percentages of the object's size. + let mut t = new_effect("distort.tweak", &json!({"h": 10, "relative": true})).unwrap(); + scale_effect(&mut t, 2.0); + assert_eq!(t.params["h"], json!(10)); +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8a13018d..3b59c9d4 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + "{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", always, list ), @@ -145,7 +145,10 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { fn list(s: &mut Session, p: &Value) -> Result { let catalog: Vec = effects::effect_catalog() .into_iter() - .map(|e| json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster})) + .map(|e| { + json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster, + "lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}}) + }) .collect(); let mut applied = vec![]; if s.active().is_some() { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 365c6238..8b471661 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Transform Each…", ["Object", "Transform"], Some("Cmd+Alt+Shift+D"), - "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool} transform every selected object about its own reference point → {ids}", + "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences)} transform every selected object about its own reference point → {ids}", has_selection, transform_each ), @@ -265,7 +265,7 @@ fn transform_each(s: &mut Session, p: &Value) -> Result { return Err(bad("object.transformEach", "scale must be non-zero")); } let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1)); - let scale_strokes = s.prefs.scale_strokes; + let sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() }; let ids = s.edit("Transform Each", |d, sel| { let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() }; for id in &targets { @@ -283,7 +283,7 @@ fn transform_each(s: &mut Session, p: &Value) -> Result { let m = Affine::translate((k_mh, k_mv)) * about(o, Affine::rotate(-k_rot.to_radians()) * refl * Affine::scale_non_uniform(k_sh / 100.0, k_sv / 100.0)); if let Some(n) = d.node_mut(*id) { - n.transform(m, scale_strokes); + n.transform(m, sc); } } Ok(targets) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 9e2b7237..a34cb4ea 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2}; use super::edit::{duplicate_in, selected_roots}; @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Transform", [], None, - "{matrix: [a,b,c,d,e,f], copy?: bool, ids?} apply an affine to the selection (or ids)", + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences)", has_doc, transform ), @@ -34,7 +34,15 @@ pub fn specs() -> Vec { has_selection, rotate ), - cmd!("object.scale", "Scale…", ["Object", "Transform"], None, "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool}", has_selection, scale), + cmd!( + "object.scale", + "Scale…", + ["Object", "Transform"], + None, + "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale)} (strokes/corners default to the preferences)", + has_selection, + scale + ), cmd!( "object.reflect", "Reflect…", @@ -183,14 +191,27 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { s.doc()?.doc.bounds_of(ids, false).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) } -/// Apply `xf` to `ids` (copy = duplicate first). Records Transform Again. -pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, copy: bool) -> Result { - let scale_strokes = s.prefs.scale_strokes; +/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from +/// its `strokes` and `corners` params, else the preferences. The values join the command's journal +/// entry, so replaying it scales the same way whatever the preferences are then. +pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling { + let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); + let corners = bool_or(p, "corners", s.prefs.scale_corners); + s.note_journal("strokes", json!(strokes)); + s.note_journal("corners", json!(corners)); + Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners } +} + +/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]). +/// Records Transform Again. +pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value) -> Result { + let copy = bool_or(p, "copy", false); + let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; let ids = s.edit(label, |d, sel| { let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; for id in &targets { if let Some(n) = d.node_mut(*id) { - n.transform(xf, scale_strokes); + n.transform(xf, sc); } } Ok(targets) @@ -208,12 +229,12 @@ fn transform(s: &mut Session, p: &Value) -> Result { Some(v) => v, None => selected_roots(s)?, }; - apply_transform(s, "Transform", ids, m, bool_or(p, "copy", false)) + apply_transform(s, "Transform", ids, m, p) } fn move_cmd(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; - apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), bool_or(p, "copy", false)) + apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p) } fn nudge(s: &mut Session, p: &Value) -> Result { @@ -224,7 +245,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result { return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy })); } let ids = selected_roots(s)?; - apply_transform(s, "Move", ids, Affine::translate((dx, dy)), bool_or(p, "copy", false)) + apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p) } fn about(o: Point, a: Affine) -> Affine { @@ -236,7 +257,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { let o = origin_of(s, p, &ids)?; // Illustrator angles are counter-clockwise; y is down, so negate. let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians())); - apply_transform(s, "Rotate", ids, a, bool_or(p, "copy", false)) + apply_transform(s, "Rotate", ids, a, p) } fn scale(s: &mut Session, p: &Value) -> Result { @@ -247,13 +268,7 @@ fn scale(s: &mut Session, p: &Value) -> Result { if sx == 0.0 || sy == 0.0 { return Err(bad("object.scale", "scale must be non-zero")); } - let prev = s.prefs.scale_strokes; - if let Some(b) = p.get("strokes").and_then(Value::as_bool) { - s.prefs.scale_strokes = b; - } - let r = apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), bool_or(p, "copy", false)); - s.prefs.scale_strokes = prev; - r + apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p) } fn reflect(s: &mut Session, p: &Value) -> Result { @@ -267,7 +282,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result { } _ => Affine::scale_non_uniform(-1.0, 1.0), }; - apply_transform(s, "Reflect", ids, about(o, m), bool_or(p, "copy", false)) + apply_transform(s, "Reflect", ids, about(o, m), p) } fn shear(s: &mut Session, p: &Value) -> Result { @@ -276,13 +291,13 @@ fn shear(s: &mut Session, p: &Value) -> Result { let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); let m = if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) }; - apply_transform(s, "Shear", ids, about(o, m), bool_or(p, "copy", false)) + apply_transform(s, "Shear", ids, about(o, m), p) } fn transform_again(s: &mut Session, _: &Value) -> Result { let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?; let ids = selected_roots(s)?; - apply_transform(s, "Transform Again", ids, m, copy) + apply_transform(s, "Transform Again", ids, m, &json!({ "copy": copy })) } enum Arrange { @@ -854,7 +869,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { let new_rp = scale * rp; let tx = f64_or(p, "x", new_rp.x) - new_rp.x; let ty = f64_or(p, "y", new_rp.y) - new_rp.y; - apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, false) + apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p) } fn expand_shape(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5875e7cc..a5421b20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -512,6 +512,9 @@ pub struct Session { pub style_libraries: cmd::stylelib::Libraries, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, + /// Parameters the running top-level command resolved from the preferences, added to its + /// journal entry so a replay does the same ([`Session::note_journal`]). + journal_note: serde_json::Map, } impl Default for Session { @@ -547,6 +550,7 @@ impl Session { freeform_point: None, style_libraries: Default::default(), recent_urls: vec![], + journal_note: Default::default(), } } @@ -638,16 +642,42 @@ impl Session { } // Only top-level commands are journaled (commands that call other commands would otherwise // be recorded twice and replay differently). + if self.depth == 0 { + self.journal_note.clear(); + } self.depth += 1; let r = (spec.run)(self, params); self.depth -= 1; let r = r?; if spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { - self.journal.push((id.to_string(), params.clone())); + let p = self.noted(params); + self.journal.push((id.to_string(), p)); } Ok(r) } + /// Record `key: value` in the running top-level command's journal entry (or its interaction's + /// preview) unless its params give `key`: a value it resolved from the preferences. + pub fn note_journal(&mut self, key: &str, value: Value) { + if self.depth == 1 { + self.journal_note.insert(key.to_string(), value); + } + } + + /// `params` with the noted values added (see [`Session::note_journal`]). + fn noted(&mut self, params: &Value) -> Value { + let mut note = std::mem::take(&mut self.journal_note); + match params { + // The params' own values win. + Value::Object(m) if !note.is_empty() => { + note.extend(m.clone()); + Value::Object(note) + } + Value::Null if !note.is_empty() => Value::Object(note), + _ => params.clone(), + } + } + /// Run `f` on a mutable copy of the active document. Outside an interaction this records one /// undo step labelled `label`; inside one it just mutates (the interaction commits later). pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { @@ -735,9 +765,10 @@ impl Session { let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params); let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); let r = self.execute(cmd, params); + let params = self.noted(params); let st = self.doc_mut()?; if let Some(it) = &mut st.interaction { - it.preview = Some((cmd.to_string(), params.clone())); + it.preview = Some((cmd.to_string(), params)); } r } @@ -877,6 +908,8 @@ mod tests_proxyitems; #[cfg(test)] mod tests_registration; #[cfg(test)] +mod tests_scalestrokes; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokereach; diff --git a/crates/engine/src/tests_scalestrokes.rs b/crates/engine/src/tests_scalestrokes.rs new file mode 100644 index 00000000..f85a6c7b --- /dev/null +++ b/crates/engine/src/tests_scalestrokes.rs @@ -0,0 +1,189 @@ +//! Scale Strokes & Effects and Scale Corners: what scales with the art, from the `strokes` and +//! `corners` params or the preferences, and what the journal records. + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveShape, Node, NodeId, NodeKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// A selected 100 pt square with a 4 pt stroke dashed [6, 2] from offset 3 and a drop shadow. +fn shadowed_dashed_square(s: &mut Session) -> NodeId { + let id = id_of(&run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))); + run(s, "stroke.set", json!({"weight": 4, "dash": [6, 2], "dashOffset": 3})); + run(s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 7, "y": 5}})); + id +} + +fn shadow(n: &Node, key: &str) -> f64 { + n.appearance.effects[0].params[key].as_f64().unwrap() +} + +#[test] +fn scaling_200_percent_doubles_the_stroke_dashes_and_shadow() { + let mut s = session(); + let id = shadowed_dashed_square(&mut s); + run(&mut s, "object.scale", json!({"sx": 200, "strokes": true})); + let n = node(&s, id); + let st = n.appearance.stroke().unwrap(); + let dash = st.dash.as_ref().unwrap(); + assert_eq!((st.width, dash.pattern.clone(), dash.offset), (8.0, vec![12.0, 4.0], 6.0)); + assert_eq!((shadow(&n, "x"), shadow(&n, "y")), (14.0, 10.0)); + // The blur was left at its default (5 pt): it scales too. + assert_eq!(shadow(&n, "blur"), 10.0); +} + +#[test] +fn off_nothing_visual_scales() { + let mut s = session(); + let id = shadowed_dashed_square(&mut s); + let before = node(&s, id).appearance; + run(&mut s, "object.scale", json!({"sx": 200, "strokes": false})); + let n = node(&s, id); + assert_eq!(n.appearance.stroke(), before.stroke()); + assert_eq!(n.appearance.effects, before.effects); + assert!((n.geometric_bounds().unwrap().width() - 200.0).abs() < 1e-9); +} + +#[test] +fn the_preference_applies_when_the_param_is_absent() { + let mut s = session(); + let id = shadowed_dashed_square(&mut s); + run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": false})); + run(&mut s, "object.scale", json!({"sx": 50})); + assert_eq!(node(&s, id).appearance.stroke_width(), 4.0); + run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": true})); + run(&mut s, "object.scale", json!({"sx": 50})); + assert_eq!(node(&s, id).appearance.stroke_width(), 2.0); +} + +#[test] +fn relative_distances_and_fill_and_stroke_effects() { + let mut s = session(); + let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + // Relative Roughen is a percentage of the size: it stays; an absolute one is a distance. + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 5, "relative": true}})); + run(&mut s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 4, "relative": false}})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 1})); + run(&mut s, "object.transformEach", json!({"scaleH": 300, "scaleV": 300, "strokes": true})); + let a = node(&s, id).appearance; + assert_eq!(a.effects[0].params["size"], json!(5)); + assert_eq!(a.effects[1].params["size"], json!(12.0)); + assert_eq!(a.items[1].effects()[0].params["offset"], json!(9.0), "the stroke's own effect"); +} + +#[test] +fn transform_each_without_strokes_keeps_them() { + let mut s = session(); + let id = shadowed_dashed_square(&mut s); + run(&mut s, "object.transformEach", json!({"scaleH": 200, "scaleV": 200, "strokes": false})); + let n = node(&s, id); + assert_eq!((n.appearance.stroke_width(), shadow(&n, "x")), (4.0, 7.0)); + assert!((n.geometric_bounds().unwrap().width() - 200.0).abs() < 1e-9); +} + +#[test] +fn off_compensates_the_type_stroke_on_scales_it_with_the_type() { + let mut s = session(); + let id = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Scale", "size": 24}))); + run(&mut s, "select.set", json!({ "ids": [id.0] })); + run(&mut s, "paint.setStroke", json!({"color": "#ff0000"})); + run(&mut s, "stroke.set", json!({"weight": 2, "dash": [4, 2], "dashOffset": 1})); + let style = |s: &Session| match &node(s, id).kind { + NodeKind::Text(t) => (t.xf.as_coeffs()[0], t.runs[0].style.clone()), + _ => panic!("not type"), + }; + run(&mut s, "object.scale", json!({"sx": 200, "strokes": false})); + let (k, st) = style(&s); + // Drawn in text space (×2), the weight and dashes look as before. + assert_eq!(k, 2.0); + let dash = st.stroke_dash.unwrap(); + assert_eq!((st.stroke_width * k, dash.pattern[0] * k, dash.offset * k), (2.0, 4.0, 1.0)); + run(&mut s, "object.scale", json!({"sx": 200, "strokes": true})); + let (k, st) = style(&s); + assert_eq!((k, st.stroke_width * k), (4.0, 4.0), "the type's scale doubles the stroke"); +} + +#[test] +fn group_appearance_scales_with_its_members() { + let mut s = session(); + let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); + let b = id_of(&run(&mut s, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50}))); + run(&mut s, "select.set", json!({ "ids": [a.0, b.0] })); + let g = id_of(&run(&mut s, "object.group", json!({}))); + run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "stylize.dropShadow", "params": {"x": 4, "y": 4}})); + run(&mut s, "object.scale", json!({"sx": 50, "strokes": true})); + let n = node(&s, g); + assert_eq!(shadow(&n, "x"), 2.0); + let NodeKind::Group { children, .. } = &n.kind else { panic!("not a group") }; + assert_eq!(children[0].appearance.stroke_width(), 0.5); +} + +#[test] +fn scale_corners_keeps_or_scales_live_corner_radii() { + let radius = |s: &Session, id| match &node(s, id).kind { + NodeKind::Path { live: Some(LiveShape::Rectangle { radii, xf, .. }), .. } => radii[0] * xf.as_coeffs()[0], + _ => panic!("not a live rectangle"), + }; + let mut s = session(); + let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10}))); + run(&mut s, "object.scale", json!({"sx": 200, "corners": false})); + assert!((radius(&s, id) - 10.0).abs() < 1e-9, "corners keep their size"); + run(&mut s, "object.scale", json!({"sx": 200, "corners": true})); + assert!((radius(&s, id) - 20.0).abs() < 1e-9); + // The path follows the live shape. + let n = node(&s, id); + assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6); +} + +#[test] +fn the_journal_records_the_scale_options_used() { + let mut s = session(); + shadowed_dashed_square(&mut s); + run(&mut s, "prefs.set", json!({"values": {"scaleStrokes": false, "scaleCorners": true}})); + run(&mut s, "object.scale", json!({"sx": 150})); + let (cmd, p) = s.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.scale"); + assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true})); + // An explicit value stays; moves don't scale, so they record nothing. + run(&mut s, "object.scale", json!({"sx": 150, "strokes": true})); + assert_eq!(s.journal.last().unwrap().1["strokes"], json!(true)); + run(&mut s, "object.move", json!({"dx": 5, "dy": 0})); + assert_eq!(s.journal.last().unwrap().1, json!({"dx": 5, "dy": 0})); + // A drag's preview is journaled at commit with the options too. + s.begin_interaction("Scale").unwrap(); + s.preview("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap(); + s.commit_interaction().unwrap(); + let (cmd, p) = s.journal.last().unwrap().clone(); + assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.transform", &json!(false), &json!(true))); +} + +#[test] +fn replaying_the_journal_entry_scales_the_same_way() { + let mut s = session(); + let id = shadowed_dashed_square(&mut s); + run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": true})); + run(&mut s, "object.scale", json!({"sx": 200})); + let (cmd, p) = s.journal.last().unwrap().clone(); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": false})); + run(&mut s, &cmd, p); + assert_eq!(node(&s, id).appearance.stroke_width(), 8.0); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 6f0accd9..6b837f65 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -39,6 +39,7 @@ pub mod swatch_options; pub mod tile_edge_color; mod tools; mod transform; +pub mod transform_each; use serde_json::{Value, json}; @@ -166,6 +167,7 @@ registry! { SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC, Expand: [expand::KIND] => expand::SPEC, SpotColors: [spot_colors::KIND] => spot_colors::SPEC, + TransformEach: [transform_each::KIND] => transform_each::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 40986887..26956743 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -1,4 +1,5 @@ -//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy, and Uniform for Scale). +//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy; Scale also has Uniform, +//! Scale Corners and Scale Strokes & Effects). use serde_json::{Value, json}; @@ -18,16 +19,52 @@ fn title(kind: &str) -> &'static str { } } -fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::grid(ui, d); ui.add_space(6.0); if d.kind == "scale" { form::check(ui, d, "uniform", "Uniform"); + scale_options(app, ui, d); } form::check(ui, d, "copy", "Copy (make a transformed copy)"); false } +/// The scale options: (dialog field, preference, label). +const SCALE_OPTIONS: [(&str, &str, &str); 2] = [("corners", "scaleCorners", "Scale Corners"), ("strokes", "scaleStrokes", "Scale Strokes & Effects")]; + +/// A scale option's value: the dialog's field, else the preference. +fn scale_option(app: &VectorcraftApp, d: &Dialog, field: &str) -> bool { + let prefs = &app.session.prefs; + d.fields.get(field).and_then(Value::as_bool).unwrap_or(if field == "corners" { prefs.scale_corners } else { prefs.scale_strokes }) +} + +/// The Scale Corners and Scale Strokes & Effects checkboxes (fields `corners` and `strokes`), +/// starting from the preferences. +pub(super) fn scale_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + for (field, _, label) in SCALE_OPTIONS { + let on = scale_option(app, d, field); + d.fields.insert(field.into(), Value::Bool(on)); + form::check(ui, d, field, label); + } +} + +/// Add the dialog's scale options to command `params`. +pub(super) fn add_scale_options(app: &VectorcraftApp, d: &Dialog, params: &mut Value) { + for (field, _, _) in SCALE_OPTIONS { + params[field] = json!(scale_option(app, d, field)); + } +} + +/// `params` with the dialog's scale options, which also become the preferences (they are shared +/// with the Transform panel and Preferences, as in the reference app). +pub(super) fn with_scale_options(app: &mut VectorcraftApp, d: &Dialog, mut params: Value) -> Result { + add_scale_options(app, d, &mut params); + let prefs: serde_json::Map = SCALE_OPTIONS.iter().map(|(field, pref, _)| (pref.to_string(), params[*field].clone())).collect(); + app.run("prefs.set", json!({ "values": prefs }))?; + Ok(params) +} + /// Pass a tool-chosen reference point (origin) through to transform commands. fn origin_params(d: &Dialog, mut p: Value) -> Value { if let Some(o) = d.fields.get("origin") { @@ -44,10 +81,44 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { "scale" => { let sx = d.f64("sx", 100.0); let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; - ("object.scale", json!({"sx": sx, "sy": sy, "copy": copy})) + ("object.scale", with_scale_options(app, d, json!({"sx": sx, "sy": sy, "copy": copy}))?) } "reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy})), _ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})), }; run_and_close(app, id, origin_params(d, params)) } + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use crate::VectorcraftApp; + + #[test] + fn scale_options_start_from_and_update_the_preferences() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50, "radius": 5})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("prefs.set", json!({"values": {"scaleStrokes": true, "scaleCorners": false}})).unwrap(); + crate::menus::invoke(&mut app, "object.scale", json!({})); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((&d.fields["strokes"], &d.fields["corners"]), (&json!(true), &json!(false))); + d.fields.insert("sx".into(), json!(200)); + d.fields.insert("strokes".into(), json!(false)); + d.fields.insert("corners".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + let prefs = &app.session.prefs; + assert_eq!((prefs.scale_strokes, prefs.scale_corners), (false, true)); + let n = &app.session.doc().unwrap().doc.layers[0].children().unwrap()[0]; + assert_eq!(n.appearance.stroke_width(), 1.0); + let (cmd, p) = app.session.journal.last().unwrap(); + assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.scale", &json!(false), &json!(true))); + } +} diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs new file mode 100644 index 00000000..71662629 --- /dev/null +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -0,0 +1,136 @@ +//! Object → Transform → Transform Each: scale, move and rotate every selected object about its own +//! reference point, with reflection, randomness, Scale Corners and Scale Strokes & Effects +//! (`object.transformEach`), previewed live on the canvas. +//! +//! Fields: `scaleH`, `scaleV` (%), `moveH`, `moveV` (pt), `rotate` (°), `reflectX`, `reflectY`, +//! `random`, `reference` (0..8, the 9-point grid), `copy`, `corners`, `strokes` (default: the +//! preferences, which OK updates) and `preview`. + +use serde_json::{Value, json}; + +use super::transform::{add_scale_options, scale_options, with_scale_options}; +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets; + +/// The dialog kind of Transform Each. +pub const KIND: &str = "transformEach"; + +const CMD: &str = "object.transformEach"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Transform Each".into(), body, confirm, preview: true, min_width: 360.0, ..DialogSpec::FORM }; + +/// The fields a fresh dialog starts with. +pub fn fields() -> Value { + json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": false, "reference": 4, "copy": false}) +} + +/// The command's parameters without the scale options. +fn params(d: &Dialog) -> Value { + let f = |k: &str, v: f64| d.f64(k, v); + json!({ + "scaleH": f("scaleH", 100.0), "scaleV": f("scaleV", 100.0), "moveH": f("moveH", 0.0), "moveV": f("moveV", 0.0), + "rotate": f("rotate", 0.0), "reflectX": d.bool("reflectX"), "reflectY": d.bool("reflectY"), "random": d.bool("random"), + "reference": f("reference", 4.0).clamp(0.0, 8.0) as u64, "copy": d.bool("copy"), + }) +} + +/// A labelled number row of the field grid: `key` shown with `suffix` (document units for "pt"). +fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, suffix: &str, units: vectorcraft_doc::Unit) { + widgets::dim_label(ui, label); + let v = d.f64(key, 0.0); + let new = if suffix == "pt" { + widgets::num_field(ui, ("te", key), Some(v), units, 90.0) + } else { + widgets::plain_field(ui, ("te", key), v, suffix, 2, 90.0) + }; + if let Some(n) = new { + d.fields.insert(key.into(), json!(n)); + } + ui.end_row(); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let units = app.session.active().map_or(vectorcraft_doc::Unit::Points, |s| s.doc.units); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + egui::Grid::new("te-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| { + for (head, rows) in [ + ("Scale", [("scaleH", "Horizontal:", "%"), ("scaleV", "Vertical:", "%")].as_slice()), + ("Move", &[("moveH", "Horizontal:", "pt"), ("moveV", "Vertical:", "pt")]), + ("Rotate", &[("rotate", "Angle:", "°")]), + ] { + widgets::subheader(ui, head); + ui.end_row(); + for (key, label, suffix) in rows { + number(ui, d, key, label, suffix, units); + } + } + }); + }); + ui.add_space(16.0); + ui.vertical(|ui| { + widgets::subheader(ui, "Options"); + ui.add_space(4.0); + scale_options(app, ui, d); + form::check(ui, d, "reflectX", "Reflect X"); + form::check(ui, d, "reflectY", "Reflect Y"); + ui.horizontal(|ui| { + let cur = d.f64("reference", 4.0).clamp(0.0, 8.0) as usize; + if let Some(i) = widgets::reference_point(ui, cur) { + d.fields.insert("reference".into(), json!(i)); + } + widgets::dim_label(ui, "Reference Point"); + }); + form::check(ui, d, "random", "Random"); + form::check(ui, d, "copy", "Copy"); + }); + }); + let mut p = params(d); + add_scale_options(app, d, &mut p); + form::preview(app, ui, d, "Transform Each", CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p = with_scale_options(app, d, params(d))?; + form::commit_preview(app, CMD, p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::NodeId; + use vectorcraft_engine::Session; + + #[test] + fn ok_transforms_each_and_keeps_the_scale_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("prefs.set", json!({"key": "scaleStrokes", "value": true})).unwrap(); + app.run("ui.menuDialog", json!({ "command": CMD })).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + // One drawn frame: the checkboxes start from the preferences. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["strokes"], json!(true)); + d.fields.insert("scaleH".into(), json!(200)); + d.fields.insert("scaleV".into(), json!(200)); + d.fields.insert("strokes".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.prefs.scale_strokes, "OK keeps the option as the preference"); + let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + assert!((n.geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); + assert_eq!(n.appearance.stroke_width(), 1.0, "strokes kept their weight"); + let (cmd, p) = app.session.journal.last().unwrap(); + assert_eq!((cmd.as_str(), &p["strokes"]), (CMD, &json!(false))); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb5dacb0..81f0f994 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -327,6 +327,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", ), + ( + "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} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -696,6 +702,13 @@ 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()), + }, _ => return None, }; Some(r) @@ -882,6 +895,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.spotColors" => app.session.active().is_some(), + "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), _ => true, } } @@ -1020,11 +1034,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Scale…", "object.scale"), c("Shear…", "object.shear"), Sep, - cp( - "Transform Each…", - "object.transformEach", - json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": false, "reference": 4}), - ), + c("Transform Each…", "object.transformEach"), Sep, c("Reset Bounding Box", "object.resetBoundingBox"), ], @@ -1592,22 +1602,26 @@ pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { (id.to_string(), if p.is_null() { json!({}) } else { p.clone() }) } +/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. +fn menu_dialog(id: &str) -> Option<(&'static str, Value)> { + Some(match id { + "object.move" => ("move", json!({"dx": "0 pt", "dy": "0 pt"})), + "object.rotate" => ("rotate", json!({"angle": 0})), + "object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})), + "object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::fields()), + "object.reflect" => ("reflect", json!({"axis": "vertical"})), + "object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})), + "path.average" => ("average", json!({"axis": "both"})), + "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), + "object.path.simplify" => ("simplify", json!({"tolerance": "1 pt"})), + "object.path.splitIntoGrid" => ("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"})), + _ => return None, + }) +} + /// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { - // Commands whose menu item opens a dialog in Illustrator. - let dialog = match id { - "object.move" => Some(("move", json!({"dx": "0 pt", "dy": "0 pt"}))), - "object.rotate" => Some(("rotate", json!({"angle": 0}))), - "object.scale" => Some(("scale", json!({"sx": 100, "sy": 100, "uniform": true}))), - "object.reflect" => Some(("reflect", json!({"axis": "vertical"}))), - "object.shear" => Some(("shear", json!({"angle": 0, "axis": "horizontal"}))), - "path.average" => Some(("average", json!({"axis": "both"}))), - "object.path.offsetPath" => Some(("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4}))), - "object.path.simplify" => Some(("simplify", json!({"tolerance": "1 pt"}))), - "object.path.splitIntoGrid" => Some(("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"}))), - _ => None, - }; - if let Some((kind, fields)) = dialog + if let Some((kind, fields)) = menu_dialog(id) && p.as_object().is_none_or(|o| o.is_empty()) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index f881815f..9556a2ee 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -190,7 +190,7 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { }); let mut ss = app.session.prefs.scale_strokes; if ui.checkbox(&mut ss, "Scale Strokes & Effects").changed() { - app.session.prefs.scale_strokes = ss; + super::transform::set_pref(app, "scaleStrokes", ss); } divider(ui); section_header(ui, "Quick Actions"); diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 6a91c42b..9bc0b674 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -126,10 +126,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; } widgets::divider(ui); - widgets::check(ui, "Scale Corners", false, false); - let ss = app.session.prefs.scale_strokes; + let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); + if widgets::check(ui, "Scale Corners", sc, true) { + set_pref(app, "scaleCorners", !sc); + } if widgets::check(ui, "Scale Strokes & Effects", ss, true) { - app.session.prefs.scale_strokes = !ss; + set_pref(app, "scaleStrokes", !ss); + } +} + +/// Toggle a boolean preference (through `prefs.set`, as agents do). +pub fn set_pref(app: &mut VectorcraftApp, key: &str, on: bool) { + if let Err(e) = app.run("prefs.set", json!({ "key": key, "value": on })) { + app.status(e); } } @@ -149,7 +158,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); let ss = app.session.prefs.scale_strokes; if menu_item(ui, "Scale Strokes & Effects", true, ss) { - app.session.prefs.scale_strokes = !ss; + set_pref(app, "scaleStrokes", !ss); } ui.separator(); menu_item(ui, "Transform Object Only", false, true); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 952f6291..f8f144b2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -152,3 +152,12 @@ Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its c Spot Colors: the Swatches panel menu's Spot Colors… (`ui.spotColors`) opens the `spotColors` dialog (field `useLab`: true shows and separates spot colours defined in Lab from their Lab values, false from their working-CMYK equivalents); `ui.dialog.confirm` runs `swatch.spotOptions {useLab}` as one undo step. + +Scale options: `ui.menuDialog {command}` opens the dialog a command's menu item opens (`object.scale` → `scale`, +`object.transformEach` → `transformEach`, also Move, Rotate, Reflect, Shear and the Path dialogs). The Scale dialog +(`scale`) and Object → Transform → Transform Each… (dialog `transformEach`: `scaleH`, +`scaleV` %, `moveH`, `moveV` pt, `rotate` °, `reflectX`, `reflectY`, `random`, `reference` 0–8, `copy`, `preview`) +have the fields `corners` (Scale Corners) and `strokes` (Scale Strokes & Effects). They start from the preferences +`scaleCorners` and `scaleStrokes`, and `ui.dialog.confirm` keeps them as the preferences (`prefs.set`) before running +`object.scale` / `object.transformEach` with them. The Transform panel's checkboxes and flyout item and the Properties +panel's checkbox set the same preferences through `prefs.set`. diff --git a/docs/mcp.md b/docs/mcp.md index bef863b5..36ecdde2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -610,3 +610,19 @@ the preference `japaneseCropMarks` is on or `style: "japanese"` is given. {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"japaneseCropMarks","value":true}}} {"name":"run_command","arguments":{"command":"object.createTrimMarks","params":{}}} ``` + +## Scale Strokes & Effects + +`object.scale`, `object.transform` and `object.transformEach` take `strokes?: bool` (Scale Strokes & Effects) and +`corners?: bool` (Scale Corners); without them the preferences `scaleStrokes` (default on) and `scaleCorners` (default +off) apply (`prefs.set {key, value}`). With strokes on, a scale by k (the square root of the transform's determinant) +multiplies stroke weights, dash lengths and dash offsets, and every distance parameter of the object's, its fills' and +its strokes' effects (drop shadow offsets and blur, offsets, radii…; `effect.list` gives each effect's `lengths`; +relative Roughen, Tweak and Zig Zag sizes stay percentages), in groups and layers too. With it off, nothing painted +changes size, and type keeps its character strokes' weight. With Scale Corners off, live corner radii keep their +size. The journal entry of a scaling command records the `strokes` and `corners` it used, so actions replay alike. + +```json +{"name":"run_command","arguments":{"command":"object.scale","params":{"sx":200,"strokes":true}}} +{"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} +```