From 5afc78a1002cc217f743f74cea9dcdf640318b0a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:28:50 +0700 Subject: [PATCH] M3.66: Scale Strokes & Effects scales dashes and effect distances; Scale Corners works Scaling with Scale Strokes & Effects on now scales stroke weights, dash lengths and dash offsets and every distance parameter of object, fill and stroke effects (groups and layers too), from new length metadata in the effects catalogue. Off, nothing painted changes size and type keeps its character strokes' weight. Scale Corners off keeps live corner radii. object.scale/transform/transformEach take strokes?/corners? (default: the preferences) and journal the values they used. The Scale dialog and a new Transform Each dialog get both checkboxes, kept as the preferences on OK; the panels' toggles go through prefs.set. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 19 +- crates/doc/src/lib.rs | 1 + crates/doc/src/node.rs | 86 +++++++-- crates/doc/src/pattern.rs | 5 +- crates/doc/src/text.rs | 10 + crates/effects/src/lib.rs | 56 +++++- crates/effects/src/tests_scale.rs | 34 ++++ crates/engine/src/cmd/effectcmd.rs | 7 +- crates/engine/src/cmd/menucmds.rs | 6 +- crates/engine/src/cmd/object.rs | 59 +++--- crates/engine/src/lib.rs | 37 +++- crates/engine/src/tests_scalestrokes.rs | 189 +++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/transform.rs | 77 +++++++- crates/ui-egui/src/dialogs/transform_each.rs | 136 +++++++++++++ crates/ui-egui/src/menus.rs | 52 +++-- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/transform.rs | 17 +- docs/control-protocol.md | 9 + docs/mcp.md | 16 ++ 20 files changed, 744 insertions(+), 76 deletions(-) create mode 100644 crates/effects/src/tests_scale.rs create mode 100644 crates/engine/src/tests_scalestrokes.rs create mode 100644 crates/ui-egui/src/dialogs/transform_each.rs 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}}} +```