Merge pull request #920 from storytold/feat/885-transform-effect-dialog

Part of #885: the Transform effect's dialog takes Transform Each's controls
This commit is contained in:
BFlat 2026-10-10 14:32:55 +07:00 committed by GitHub
commit dd61dfa146
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 291 additions and 68 deletions

File diff suppressed because one or more lines are too long

View File

@ -102,25 +102,42 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
PathData::new(subs)
}
/// Transform effect (scale, move, rotate, reflect about the centre, plus copies).
pub fn transform(path: &PathData, b: Rect, p: &Value) -> PathData {
let sx = num(p, "scaleH", 100.0) / 100.0;
let sy = num(p, "scaleV", 100.0) / 100.0;
let (rx, ry) = (if flag(p, "reflectX", false) { -1.0 } else { 1.0 }, if flag(p, "reflectY", false) { -1.0 } else { 1.0 });
let c = b.center().to_vec2();
// Reflect X mirrors across the horizontal axis (flips x coordinates in Illustrator's dialog).
let m = Affine::translate(Vec2::new(num(p, "moveH", 0.0), num(p, "moveV", 0.0)) + c)
* Affine::rotate(-num(p, "rotate", 0.0).to_radians())
* Affine::scale_non_uniform(sx * rx, sy * ry)
* Affine::translate(-c);
/// Largest scale (%) and move (pt) the Transform effect takes.
const MAX_SCALE: f64 = 100_000.0;
const MAX_MOVE: f64 = 1.0e7;
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
const MAX_REACH: f64 = 1.0e9;
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
/// `random`, each scale goes a random share of the way from 100 % to its value and each move and
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
/// on every redraw while each object varies its own way.
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
let random = flag(p, "random", false);
// The share (0..1) of value `k` applied.
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
let scale = |key: &str, k: u64| 1.0 + (num(p, key, 100.0).clamp(-MAX_SCALE, MAX_SCALE) / 100.0 - 1.0) * share(k);
let moved = |key: &str, k: u64| num(p, key, 0.0).clamp(-MAX_MOVE, MAX_MOVE) * share(k);
let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 };
let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2();
// Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates.
let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o)
* Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians())
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
* Affine::translate(-o);
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
if copies == 0 {
return path.transformed(m);
}
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within) {
break;
}
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out

View File

@ -319,6 +319,17 @@ mod tests {
assert_eq!(div.len(), 3);
}
#[test]
fn a_random_transform_on_a_group_moves_its_members_together() {
let mut g = group_with("distort.transform");
g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true});
let art = evaluate_container(&g).unwrap();
let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap();
let (a, b) = (corner(0), corner(1));
assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}");
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");

View File

@ -207,8 +207,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0), moveV: pt (0), rotate: deg (0), copies: int (0), reflectX: bool, reflectY: bool}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false}),
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw)}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false}),
),
g(
"distort.tweak",
@ -489,6 +489,9 @@ pub struct GeomContext<'a> {
pub stroke: Option<&'a StrokeLayer>,
/// The object's fill rule (inside and outside alignment).
pub rule: FillRule,
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
/// way and keeps its result on every redraw.
pub seed: u64,
}
impl<'a> GeomContext<'a> {
@ -498,7 +501,7 @@ impl<'a> GeomContext<'a> {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule }
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@ -551,7 +554,7 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
"distort.freeDistort" => distort::free_distort(path, b, p),
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
"distort.roughen" => distort::roughen(path, b, p),
"distort.transform" => distort::transform(path, b, p),
"distort.transform" => distort::transform(path, b, p, ctx.seed),
"distort.tweak" => distort::tweak(path, b, p),
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)),
"distort.zigZag" => distort::zig_zag(path, b, p),

View File

@ -197,14 +197,15 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
}
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box.
/// its reference box and the art's seed (a random Transform moves the pieces together).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let seed = art.id.0;
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
set_geometry(leaf, out, rule);
});
}

View File

@ -171,6 +171,47 @@ fn transform_copies_and_move() {
assert!(close(f.y0, 5.0, 1e-9));
}
#[test]
fn transform_scales_and_rotates_about_its_reference_point() {
// Bottom right (8) stays put while the square halves; the top left (0) while it rotates.
let r = run("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8}), &square()).bounds().unwrap();
assert!(close(r.x0, 50.0, 1e-9) && close(r.y0, 50.0, 1e-9) && close(r.x1, 100.0, 1e-9) && close(r.y1, 100.0, 1e-9), "{r:?}");
let r = run("distort.transform", json!({"rotate": 90, "reference": 0}), &square()).bounds().unwrap();
assert!(close(r.x0, 0.0, 1e-9) && close(r.x1, 100.0, 1e-9) && close(r.y0, -100.0, 1e-9) && close(r.y1, 0.0, 1e-9), "{r:?}");
// Reflect X about the right edge's middle (5): a mirrored copy beside the original.
let out = run("distort.transform", json!({"reflectX": true, "reference": 5, "copies": 1}), &square());
assert_eq!(out.bounds().map(|b| (b.x0.round(), b.x1.round())), Some((0.0, 200.0)));
// A point off the grid clamps to it.
assert_eq!(run("distort.transform", json!({"scaleH": 50, "reference": 99}), &square()).bounds().map(|b| b.x0), Some(50.0));
}
#[test]
fn random_transform_is_stable_per_object_and_within_the_values() {
let b = Rect::new(0.0, 0.0, 100.0, 100.0);
let bounds = |params: Value, seed| {
apply_geometry_with(&[fx("distort.transform", params)], &square(), b, &GeomContext { seed, ..Default::default() }).bounds().unwrap()
};
let random = |seed| bounds(json!({"scaleH": 50, "moveH": 40, "random": true}), seed);
let first = random(7);
assert_eq!(random(7), first, "the same object always gets the same result");
assert_ne!(random(8), first, "another object varies its own way");
for seed in 0..50 {
let r = random(seed);
// Scale between 100 % and 50 %, move between 0 and 40 pt.
assert!(r.width() >= 50.0 - 1e-9 && r.width() <= 100.0 + 1e-9, "{r:?}");
assert!(r.center().x >= 50.0 - 1e-9 && r.center().x <= 90.0 + 1e-9, "{r:?}");
}
// Without Random the values apply in full whatever the seed.
let r = bounds(json!({"scaleH": 50, "moveH": 40}), 3);
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
}
#[test]
fn transform_copies_stop_before_they_overflow() {
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len());
}
#[test]
fn pucker_bloat_moves_anchors_in_opposite_directions() {
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());

View File

@ -11,7 +11,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_color::Color;
use super::{DialogSpec, form};
use super::{DialogSpec, form, transform_each};
use crate::panels::c32;
use crate::state::Dialog;
use crate::theme::Tokens;
@ -131,6 +131,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = match vectorcraft_plugins::effect::installed(&id) {
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
@ -138,8 +139,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
};
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, tl!("Preview")).changed();
let pv_changed = widgets::check(ui, tl!("Preview"), d.bool("preview"), true);
let pv = d.bool("preview") != pv_changed;
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
@ -261,12 +262,42 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, key, label);
}
}
values_changed(d)
}
/// Do the dialog's values differ from those last previewed? (Remembers them for the next frame.)
fn values_changed(d: &mut Dialog) -> bool {
let params = form::params(d);
let changed = d.fields.get(form::PREVIEWED) != Some(&params);
d.fields.insert(form::PREVIEWED.into(), params);
changed
}
/// The Transform effect (Effect › Distort & Transform › Transform…).
const TRANSFORM: &str = "distort.transform";
/// The Transform effect's dialog: Transform Each's Scale and Move sliders and Rotate dial, then
/// Copies, the reflections, the reference point and Random. Returns whether the values differ from
/// those last previewed.
fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| transform_each::sections(ui, d, unit));
ui.add_space(16.0);
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Options"));
ui.add_space(4.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Copies:"));
if let Some(n) = widgets::range_field(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) {
d.fields.insert("copies".into(), json!(n));
}
});
transform_each::reflect_options(ui, d);
});
});
values_changed(d)
}
/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it
/// between its neighbours, press on the graph away from the points to add one there, drag one
/// off the graph to remove it (two always stay).

View File

@ -129,11 +129,11 @@ pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32
r.inner
}
/// A checkbox bound to `d.fields[key]`.
/// A checkbox bound to `d.fields[key]` (its box shows while unchecked too).
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, tl!(label)).changed() {
d.fields.insert(key.into(), Value::Bool(b));
let b = d.bool(key);
if crate::widgets::check(ui, label, b, true) {
d.fields.insert(key.into(), Value::Bool(!b));
}
}
@ -410,14 +410,19 @@ pub(super) fn slider_field(
) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
let mut new = None;
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
new = Some((min + x as f64 * (max - min)).round());
slider_rail(ui, d, key, range, track);
if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
new = Some(x.round().clamp(min, max));
}
if let Some(n) = new.filter(|n| *n != v) {
}
/// The rail of [`slider_field`] alone: dragging it sets the number `d.fields[key]` to a whole
/// number in `range` (a value past the range shows at its end until the rail moves).
pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive<f64>, track: &dyn Fn(f32) -> egui::Color32) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
let (dragged, _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track);
if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
}

View File

@ -371,6 +371,22 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() {
assert!(app.ui.dialog.is_none());
let after = bounds(&app);
assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}");
// Transform Each, then the Transform effect: Scale › Horizontal, a plain number, comes first.
let before = bounds(&app);
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("50".into())]);
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none());
let after = bounds(&app);
assert!((after.width() - before.width() / 2.0).abs() < 1e-6 && (after.height() - before.height()).abs() < 1e-6, "{before:?} → {after:?}");
app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("50".into())]);
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none());
let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0)));
}
/// #793: Artboard Options lists its fields as Name, Width, Height, not by key.
@ -383,3 +399,50 @@ fn artboard_options_lists_name_then_width_and_height() {
let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} in {text}"));
assert!(at("Name") < at("Width") && at("Width") < at("Height"), "{text}");
}
#[test]
fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_again() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
for s in [
"Transform",
"Scale",
"Move",
"Rotate",
"Horizontal:",
"Vertical:",
"Angle:",
"Copies:",
"Reflect X",
"Reflect Y",
"Reference Point",
"Random",
"Preview",
] {
assert!(text.lines().any(|l| l == s), "{s} in {text}");
}
assert!(app.session.in_interaction(), "the preview runs");
let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone();
// The reference point and Random update the preview.
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in [("moveH", json!(30)), ("copies", json!(3)), ("reference", json!(8))] {
d.fields.insert(k.into(), v);
}
frame(&mut app, Default::default());
assert_eq!((effect(&app).params["reference"].clone(), effect(&app).params["copies"].clone()), (json!(8), json!(3)));
app.ui.dialog.as_mut().unwrap().fields.insert("random".into(), json!(true));
frame(&mut app, Default::default());
assert_eq!(effect(&app).params["random"], json!(true));
confirm(&mut app).unwrap();
assert!(!app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.len(), 1);
// Editing it from the Appearance panel shows the same values.
app.run("effect.dialog", json!({"effect": "distort.transform", "index": 0})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("reference", 4.0), d.f64("copies", 0.0), d.f64("moveH", 0.0), d.bool("random")), (8.0, 3.0, 30.0, true));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(text.lines().any(|l| l == "Reference Point"), "{text}");
}

View File

@ -1,6 +1,7 @@
//! 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.
//! (`object.transformEach`), previewed live on the canvas. Its Scale and Move sliders, Rotate dial
//! and reflection, reference point and Random options are the Transform effect's too.
//!
//! Fields: `scaleH`, `scaleV` (%), `moveH`, `moveV` (pt), `rotate` (°), `reflectX`, `reflectY`,
//! `random`, `reference` (0..8, the 9-point grid), `copy`, `corners`, `strokes` (default: the
@ -12,6 +13,7 @@ use super::transform::{add_scale_options, scale_options, with_scale_options};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// The dialog kind of Transform Each.
@ -37,51 +39,71 @@ fn params(d: &Dialog) -> Value {
})
}
/// A labelled number row of the field grid: `key` shown with `suffix` (a distance in `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);
if suffix == "pt" {
form::length(ui, d, key, units, 90.0);
} else if let Some(n) = widgets::plain_field(ui, ("te", key), d.f64(key, 0.0), suffix, 2, 90.0) {
d.fields.insert(key.into(), json!(n));
/// Width of the label column and the value fields of [`sections`].
const LABEL_W: f32 = 72.0;
const FIELD_W: f32 = 70.0;
/// Largest scale (%) the fields take (as `object.transformEach` and the Transform effect).
const MAX_SCALE: f64 = 100_000.0;
/// The Scale, Move and Rotate sections of Transform Each and the Transform effect: sliders for
/// `scaleH` and `scaleV` (rails 0–200 %) and for `moveH` and `moveV` (rails ±1000 pt, fields in
/// `units`), and an angle dial for `rotate` (counter-clockwise). The fields take values past the
/// rails.
pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit) {
let rail = Tokens::get(ui.ctx()).input_border;
let track = move |_| rail;
let axes = |h, v| [(h, tl!("Horizontal:")), (v, tl!("Vertical:"))];
widgets::subheader(ui, tl!("Scale"));
for (key, label) in axes("scaleH", "scaleV") {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::slider_rail(ui, d, key, 0.0..=200.0, &track);
if let Some(n) = widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) {
d.fields.insert(key.into(), json!(n));
}
});
}
ui.end_row();
widgets::subheader(ui, tl!("Move"));
for (key, label) in axes("moveH", "moveV") {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::slider_rail(ui, d, key, -1000.0..=1000.0, &track);
form::length(ui, d, key, units, FIELD_W);
});
}
widgets::subheader(ui, tl!("Rotate"));
widgets::label_row(ui, tl!("Angle:"), LABEL_W, |ui| {
let angle = d.f64("rotate", 0.0);
let dialed = widgets::angle_dial(ui, "te-dial", angle, 28.0);
if let Some(a) = widgets::plain_field(ui, ("te", "rotate"), angle, "°", 2, FIELD_W).or(dialed) {
d.fields.insert("rotate".into(), json!(a));
}
});
}
/// The Reflect X, Reflect Y, reference point and Random options of Transform Each and the
/// Transform effect.
pub(super) fn reflect_options(ui: &mut egui::Ui, d: &mut Dialog) {
form::check(ui, d, "reflectX", tl!("Reflect X"));
form::check(ui, d, "reflectY", tl!("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, tl!("Reference Point"));
});
form::check(ui, d, "random", tl!("Random"));
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let units = app.session.general_unit();
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 [
(tl!("Scale"), [("scaleH", tl!("Horizontal:"), "%"), ("scaleV", tl!("Vertical:"), "%")].as_slice()),
(tl!("Move"), &[("moveH", tl!("Horizontal:"), "pt"), ("moveV", tl!("Vertical:"), "pt")]),
(tl!("Rotate"), &[("rotate", tl!("Angle:"), "°")]),
] {
widgets::subheader(ui, head);
ui.end_row();
for (key, label, suffix) in rows {
number(ui, d, key, label, suffix, units);
}
}
});
});
ui.vertical(|ui| sections(ui, d, units));
ui.add_space(16.0);
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Options"));
ui.add_space(4.0);
scale_options(app, ui, d);
form::check(ui, d, "reflectX", tl!("Reflect X"));
form::check(ui, d, "reflectY", tl!("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, tl!("Reference Point"));
});
form::check(ui, d, "random", tl!("Random"));
reflect_options(ui, d);
form::check(ui, d, "copy", tl!("Copy"));
});
});

View File

@ -418,6 +418,7 @@ pub fn mixed_field(
vectorcraft_doc::parse_number(&bare.replace(['%', '°'], ""))
};
let shown = value.map(show).unwrap_or_default();
take_dialog_focus(ui, id, &shown);
let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1);
select_all_on_focus(ui, &resp, &buf);
// ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on
@ -895,6 +896,31 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
out
}
/// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees,
/// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole
/// degrees (-179..180), Shift in 45° steps. Returns the new angle.
pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, angle: f64, size: f32) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag());
let (c, r) = (rect.center(), size / 2.0 - 1.0);
let ring = if resp.hovered() || resp.dragged() { t.text } else { t.input_border };
ui.painter().circle(c, r, t.input, Stroke::new(1.0, ring));
let a = (angle as f32).to_radians();
ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text));
ui.painter().circle_filled(c, 2.0, t.text);
let (p, _) = pointer_phase(&resp)?;
let v = p - c;
if v.length() < 2.0 {
return None;
}
let step = if ui.input(|i| i.modifiers.shift) { 45.0 } else { 1.0 };
let deg = ((-v.y).atan2(v.x).to_degrees() as f64 / step).round() * step;
// atan2 gives -180..180: -180 shows as 180.
let deg = if deg <= -180.0 { deg + 360.0 } else { deg };
(deg != angle).then_some(deg)
}
/// A toggle icon (e.g. eye / lock columns) — returns clicked.
pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());

View File

@ -130,6 +130,9 @@ effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setPara
`effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the
`effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard",
value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it.
The Transform effect's dialog (`effect.dialog {effect: "distort.transform"}`) has Transform Each's controls: sliders
for `scaleH`, `scaleV` (%) and `moveH`, `moveV` (pt), an angle dial for `rotate`, then `copies`, `reflectX`,
`reflectY`, `reference` (0–8, the 9-point grid) and `random` (each object varies its own way, the same on every redraw).
Plug-in dialogs: `engine.execute {command: "plugin.dialog", params: {id}}` (Object › Plug-ins) runs an object filter
plug-in at once when it takes no parameters, else opens the `plugin` dialog: one field per declared parameter (named