mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
Merge pull request #54 from storytold/pr/m3-scale-strokes-preview-bounds
M3: Scale Strokes & Effects complete, Scale Corners, Use Preview Bounds (M3.66, M3.67)
This commit is contained in:
@@ -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<Item = &mut vectorcraft_color::GradientPaint> {
|
||||
self.items.iter_mut().filter_map(|i| match i {
|
||||
|
||||
@@ -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};
|
||||
|
||||
+74
-12
@@ -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<fn(&mut crate::Effect, f64)>,
|
||||
/// 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<f64> {
|
||||
let k = a.determinant().abs().sqrt();
|
||||
((k - 1.0).abs() > 1e-9).then_some(k)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> 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<Scaling>) {
|
||||
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).
|
||||
|
||||
@@ -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<crate::node::Scaling>) {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<EffectInfo> {
|
||||
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<Effect> {
|
||||
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")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let catalog: Vec<Value> = 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() {
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
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)
|
||||
|
||||
+108
-54
@@ -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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
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…",
|
||||
@@ -140,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\"}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -149,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\"}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
has_multi,
|
||||
distribute
|
||||
),
|
||||
@@ -158,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute Spacing",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt}",
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
has_multi,
|
||||
distribute_spacing
|
||||
),
|
||||
@@ -167,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Bounds",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?} (Transform panel)",
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
has_selection,
|
||||
set_bounds
|
||||
),
|
||||
@@ -180,17 +188,30 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||
if let Some(o) = point_param(p, "origin") {
|
||||
return Ok(o);
|
||||
}
|
||||
s.doc()?.doc.bounds_of(ids, false).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
|
||||
s.transform_bounds(ids).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<NodeId>, xf: Affine, copy: bool) -> Result<Value> {
|
||||
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<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
@@ -224,7 +245,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
@@ -247,13 +268,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
@@ -267,7 +282,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => 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<Value> {
|
||||
@@ -276,13 +291,13 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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 {
|
||||
@@ -695,34 +710,58 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Rect> {
|
||||
impl Session {
|
||||
/// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure:
|
||||
/// visual bounds (strokes included) with Use Preview Bounds on, else geometric bounds.
|
||||
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
|
||||
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
|
||||
fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||||
match str_param(p, "bounds") {
|
||||
None => Ok(s.prefs.use_preview_bounds),
|
||||
Some("preview") => Ok(true),
|
||||
Some("geometric") => Ok(false),
|
||||
Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ids` with their bounds (`preview`: visual bounds).
|
||||
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
||||
let st = s.doc()?;
|
||||
match str_param(p, "to") {
|
||||
Some("artboard") => {
|
||||
let b = st.doc.bounds_of(ids, false).unwrap_or_default();
|
||||
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
|
||||
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
|
||||
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
|
||||
}
|
||||
Some("key") => {
|
||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||
st.doc.node(k).and_then(|n| n.geometric_bounds()).ok_or(EngineError::NoNode(k))
|
||||
st.doc.bounds_of(&[k], preview).ok_or(EngineError::NoNode(k))
|
||||
}
|
||||
_ => st.doc.bounds_of(ids, false).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
||||
_ => st.doc.bounds_of(ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let r = reference_rect(s, p, &ids)?;
|
||||
let preview = preview_bounds(s, p, "object.align")?;
|
||||
let r = reference_rect(s, p, &ids, preview)?;
|
||||
let h = str_param(p, "horizontal");
|
||||
let v = str_param(p, "vertical");
|
||||
let key = s.doc()?.selection.key;
|
||||
let moves: Vec<(NodeId, Vec2)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
ids.iter()
|
||||
.filter(|id| Some(**id) != key || str_param(p, "to") != Some("key"))
|
||||
.filter_map(|id| {
|
||||
let b = d.node(*id)?.geometric_bounds()?;
|
||||
items_bounds(d, &ids, preview)
|
||||
.into_iter()
|
||||
.filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key"))
|
||||
.map(|(id, b)| {
|
||||
let dx = match h {
|
||||
Some("left") => r.x0 - b.x0,
|
||||
Some("center") => r.center().x - b.center().x,
|
||||
@@ -735,7 +774,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("bottom") => r.y1 - b.y1,
|
||||
_ => 0.0,
|
||||
};
|
||||
Some((*id, Vec2::new(dx, dy)))
|
||||
(id, Vec2::new(dx, dy))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -752,8 +791,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let d0 = &s.doc()?.doc;
|
||||
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect();
|
||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
|
||||
(Some(h), _) => (
|
||||
true,
|
||||
@@ -803,8 +842,8 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let horiz = str_param(p, "axis") != Some("vertical");
|
||||
let d0 = &s.doc()?.doc;
|
||||
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect();
|
||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
let n = items.len();
|
||||
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
|
||||
@@ -836,25 +875,40 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let b = s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
// With Use Preview Bounds the values measure the visual box, whose margins around the
|
||||
// geometric box (left, top, right, bottom) scale with the strokes or stay.
|
||||
let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g };
|
||||
let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1];
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(b, refi);
|
||||
let mut w = f64_or(p, "width", b.width());
|
||||
let mut h = f64_or(p, "height", b.height());
|
||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||
let mut w = f64_or(p, "width", v.width());
|
||||
let mut h = f64_or(p, "height", v.height());
|
||||
if bool_or(p, "proportional", false) {
|
||||
if p.get("width").is_some() && b.width() > 0.0 {
|
||||
h = b.height() * w / b.width();
|
||||
} else if p.get("height").is_some() && b.height() > 0.0 {
|
||||
w = b.width() * h / b.height();
|
||||
if p.get("width").is_some() && v.width() > 0.0 {
|
||||
h = v.height() * w / v.width();
|
||||
} else if p.get("height").is_some() && v.height() > 0.0 {
|
||||
w = v.width() * h / v.height();
|
||||
}
|
||||
}
|
||||
let sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 };
|
||||
let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 };
|
||||
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`
|
||||
// (exact for proportional scales, which scale the visual box alike).
|
||||
let strokes = bool_or(p, "strokes", s.prefs.scale_strokes);
|
||||
let ratio = |size: f64, geo: f64, margin: f64| if geo > 1e-9 { (size - margin) / geo } else { 1.0 };
|
||||
let f = |sx: f64, sy: f64| if strokes { (sx * sy).abs().sqrt() } else { 1.0 };
|
||||
let (lx, ly) = (ratio(w, v.width(), 0.0), ratio(h, v.height(), 0.0));
|
||||
let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 });
|
||||
let sx = ratio(w, g.width(), (m[0] + m[2]) * f0);
|
||||
let sy = ratio(h, g.height(), (m[1] + m[3]) * f0);
|
||||
let scale = about(rp, Affine::scale_non_uniform(sx, sy));
|
||||
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)
|
||||
// Move the new visual box's reference point to x, y (default: where it was): the geometric
|
||||
// box's reference point moves with the art, the margin from it to the visual one by `f`.
|
||||
let gp = vectorcraft_geom::reference_point(g, refi);
|
||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||
let tx = f64_or(p, "x", rp.x) - new_rp.x;
|
||||
let ty = f64_or(p, "y", rp.y) - new_rp.y;
|
||||
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String, Value>,
|
||||
}
|
||||
|
||||
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<T>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>) -> Result<T> {
|
||||
@@ -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
|
||||
}
|
||||
@@ -873,10 +904,14 @@ mod tests_pattern;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_previewbounds;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_registration;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokereach;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Use Preview Bounds: the Transform panel values, the bounding box, Align and Distribute measure
|
||||
//! visual bounds (strokes included) when it is on.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// A rectangle with a `weight` pt stroke (0: no stroke).
|
||||
fn rect(s: &mut Session, x: f64, w: f64, weight: f64) -> NodeId {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 100, "width": w, "height": 50}))["id"].as_u64().unwrap());
|
||||
if weight > 0.0 {
|
||||
run(s, "stroke.set", json!({"weight": weight}));
|
||||
} else {
|
||||
run(s, "paint.setStroke", json!({"none": true}));
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn geometric(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, on: bool) {
|
||||
run(s, "prefs.set", json!({"key": "usePreviewBounds", "value": on}));
|
||||
}
|
||||
|
||||
/// Two rectangles: one with a 10 pt stroke at x = 100, an unstroked one at x = 20; align left.
|
||||
fn aligned_left(on: bool, p: Value) -> f64 {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 50.0, 10.0);
|
||||
rect(&mut s, 20.0, 50.0, 0.0);
|
||||
preview(&mut s, on);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let mut p = p;
|
||||
p["horizontal"] = json!("left");
|
||||
run(&mut s, "object.align", p);
|
||||
geometric(&s, a).x0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_left_differs_by_half_the_stroke() {
|
||||
assert_eq!(aligned_left(false, json!({})), 20.0);
|
||||
// The 10 pt stroke's outer edge lines up with the other object: the path sits 5 pt in.
|
||||
assert_eq!(aligned_left(true, json!({})), 25.0);
|
||||
// `bounds` overrides the preference either way.
|
||||
assert_eq!(aligned_left(true, json!({"bounds": "geometric"})), 20.0);
|
||||
assert_eq!(aligned_left(false, json!({"bounds": "preview"})), 25.0);
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 10.0, 0.0);
|
||||
assert!(s.execute("object.align", &json!({"horizontal": "left", "bounds": "visual"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_spacing_measures_preview_bounds() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 50.0, 10.0);
|
||||
let b = rect(&mut s, 200.0, 50.0, 10.0);
|
||||
preview(&mut s, true);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "object.distributeSpacing", json!({"axis": "horizontal", "spacing": 0}));
|
||||
// The strokes touch: the paths are a stroke weight apart.
|
||||
assert_eq!(geometric(&s, b).x0 - geometric(&s, a).x1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_w_field_sets_the_visual_width() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 10.0);
|
||||
preview(&mut s, true);
|
||||
assert_eq!(s.transform_bounds(&[id]).unwrap().width(), 110.0);
|
||||
// Strokes scale: the whole visual box doubles, from its left edge.
|
||||
run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "proportional": true, "strokes": true}));
|
||||
let v = s.transform_bounds(&[id]).unwrap();
|
||||
assert!((v.width() - 220.0).abs() < 1e-9 && (v.x0 - 95.0).abs() < 1e-9, "{v:?}");
|
||||
assert!((geometric(&s, id).width() - 200.0).abs() < 1e-9);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// Strokes kept: the path takes the difference.
|
||||
run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "strokes": false}));
|
||||
let v = s.transform_bounds(&[id]).unwrap();
|
||||
assert!((v.width() - 220.0).abs() < 1e-9 && (v.x0 - 95.0).abs() < 1e-9, "{v:?}");
|
||||
assert!((geometric(&s, id).width() - 210.0).abs() < 1e-9);
|
||||
// X places the visual box's reference point.
|
||||
run(&mut s, "object.setBounds", json!({"x": 0, "reference": 0}));
|
||||
assert!((s.transform_bounds(&[id]).unwrap().x0).abs() < 1e-9);
|
||||
assert!((geometric(&s, id).x0 - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bounding_box_handles_sit_on_the_preview_bounds() {
|
||||
// Drag from the visual right edge (10 pt outside the path) 100 pt to the right.
|
||||
let drag = |on: bool| {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 20.0);
|
||||
preview(&mut s, on);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (k, x) in [(PointerKind::Down, 210.0), (PointerKind::Drag, 310.0), (PointerKind::Up, 310.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, 125.0), v).unwrap();
|
||||
}
|
||||
geometric(&s, id)
|
||||
};
|
||||
assert!(drag(true).width() > 150.0, "a scale from the visual edge");
|
||||
assert_eq!(drag(false).width(), 100.0, "off, the point is no handle");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ impl Session {
|
||||
constrain_angle: self.prefs.constrain_angle,
|
||||
freeform_point: self.selected_freeform_point(),
|
||||
raster_sample: self.prefs.eyedropper.sample_size,
|
||||
preview_bounds: self.prefs.use_preview_bounds,
|
||||
};
|
||||
f(self.tool.as_mut(), &cx)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ pub struct ToolContext<'a> {
|
||||
/// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper
|
||||
/// samples an image (1, 3 or 5).
|
||||
pub raster_sample: u32,
|
||||
/// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included).
|
||||
pub preview_bounds: bool,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
@@ -357,6 +359,7 @@ pub(crate) mod testutil {
|
||||
constrain_angle: 0.0,
|
||||
freeform_point: None,
|
||||
raster_sample: 1,
|
||||
preview_bounds: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,10 @@ pub fn matrix_json(a: Affine) -> Value {
|
||||
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
|
||||
}
|
||||
|
||||
/// Selection bounds used for the bounding box (geometric bounds, like Illustrator's default
|
||||
/// "Use Preview Bounds" off).
|
||||
/// Selection bounds used for the bounding box: visual bounds with Use Preview Bounds, else
|
||||
/// geometric bounds.
|
||||
pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> {
|
||||
cx.doc.bounds_of(&cx.selection.objects, false)
|
||||
cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds)
|
||||
}
|
||||
|
||||
impl SelectionTool {
|
||||
@@ -143,9 +143,8 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
let mut d = move_delta(start, p, m.shift);
|
||||
if !began {
|
||||
let sel_ids: Vec<vectorcraft_doc::NodeId> = cx.selection.objects.clone();
|
||||
self.start_bounds = cx.doc.bounds_of(&sel_ids, false);
|
||||
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &sel_ids, None));
|
||||
self.start_bounds = selection_bounds(cx);
|
||||
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
|
||||
}
|
||||
self.guides.clear();
|
||||
if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) {
|
||||
|
||||
@@ -915,7 +915,7 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
// Bounding box with handles (Selection tool).
|
||||
if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() {
|
||||
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return };
|
||||
let Some(b) = app.session.transform_bounds(&st.selection.objects) else { return };
|
||||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||||
p.add(Shape::closed_line(xf.quad(b), Stroke::new(1.0, color)));
|
||||
for h in vectorcraft_tools::bbox::Handle::ALL {
|
||||
|
||||
@@ -179,7 +179,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
// Transform fields.
|
||||
let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false));
|
||||
let b = app.session.active().and_then(|s| app.session.transform_bounds(&s.selection.objects));
|
||||
if let Some(b) = b {
|
||||
for (k, lbl, v) in
|
||||
[("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<Value, String> {
|
||||
add_scale_options(app, d, &mut params);
|
||||
let prefs: serde_json::Map<String, Value> = 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<Value, String> {
|
||||
"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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
+33
-19
@@ -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<R
|
||||
"ui.expandDialog" => crate::dialogs::expand::open(app),
|
||||
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
|
||||
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
|
||||
"ui.menuDialog" => match s("command").as_deref().and_then(menu_dialog) {
|
||||
Some((kind, fields)) => {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
|
||||
},
|
||||
_ => 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<Item>)> {
|
||||
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));
|
||||
|
||||
@@ -129,7 +129,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
|
||||
set_pstate(ui.ctx(), "align-hide-options", !hidden);
|
||||
}
|
||||
menu_item(ui, "Use Preview Bounds", false, false);
|
||||
let pb = app.session.prefs.use_preview_bounds;
|
||||
if menu_item(ui, "Use Preview Bounds", true, pb) {
|
||||
super::transform::set_pref(app, "usePreviewBounds", !pb);
|
||||
}
|
||||
let to: AlignTo = pstate(ui.ctx(), "align-to");
|
||||
if menu_item(ui, "Cancel Key Object", to == AlignTo::Key, false) {
|
||||
set_pstate(ui.ctx(), "align-to", AlignTo::Selection);
|
||||
|
||||
@@ -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");
|
||||
@@ -208,7 +208,7 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let units = st.doc.units;
|
||||
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else {
|
||||
let Some(b) = app.session.transform_bounds(&st.selection.objects) else {
|
||||
dim_label(ui, "No Selection");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
};
|
||||
let units = st.doc.units;
|
||||
let bounds = st.doc.bounds_of(&st.selection.objects, false);
|
||||
let bounds = app.session.transform_bounds(&st.selection.objects);
|
||||
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
||||
let link: bool = pstate(ui.ctx(), "xf-link");
|
||||
let has = bounds.is_some();
|
||||
@@ -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);
|
||||
@@ -163,6 +172,38 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One headless frame of the panel (or the Align panel's menu): the texts drawn.
|
||||
fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<String> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
||||
app.run("stroke.set", json!({"weight": 10})).unwrap();
|
||||
assert!(texts(&mut app, show).iter().any(|t| t == "100 pt"));
|
||||
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds"));
|
||||
set_pref(&mut app, "usePreviewBounds", true);
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}");
|
||||
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_proportions() {
|
||||
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));
|
||||
|
||||
@@ -152,3 +152,16 @@ 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`.
|
||||
|
||||
Use Preview Bounds: the Align panel flyout's Use Preview Bounds item shows and toggles the `usePreviewBounds`
|
||||
preference (`prefs.set`); while it is on, the bounding box and the Transform panel, Properties and Control bar
|
||||
fields measure visual bounds.
|
||||
|
||||
+29
@@ -610,3 +610,32 @@ 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}}}
|
||||
```
|
||||
|
||||
## Use Preview Bounds
|
||||
|
||||
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel
|
||||
flyout's Use Preview Bounds), the Transform panel's and Control bar's X, Y, W and H, the bounding box, and
|
||||
`object.align`, `object.distribute` and `object.distributeSpacing` measure visual bounds, which take in the whole
|
||||
stroke (see Stroke bounds and clicks); off, they measure the paths. The three Align commands also take
|
||||
`bounds: "preview"|"geometric"` for one call. `object.setBounds` then sets the visual box: with Scale Strokes &
|
||||
Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets a 210 pt path.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","bounds":"preview"}}}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user