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:
12 files changed
+291
-68
No files matched your search
+2
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -220,7 +220,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 89% | Scatter, Art and Pattern Brush Options (Calligraphic and Bristle done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 10–18 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges are done | 26–40 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, ¶ms, &path, bounds, &GeomContext::of(leaf));
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(¶ms);
|
||||
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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user