mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #6927 from dotCooCoo/fix/roughen-size-zero
Effects: Roughen at size 0 keeps the outline
This commit is contained in:
@@ -258,7 +258,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.roughen",
|
||||
"Roughen…",
|
||||
DT,
|
||||
"{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
|
||||
"{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
|
||||
json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}),
|
||||
),
|
||||
g(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools::insert_at;
|
||||
|
||||
use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points};
|
||||
|
||||
@@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000;
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where
|
||||
/// that would make more than `max` anchors, the points are spaced further apart until it doesn't
|
||||
/// (stacked Roughens multiply their points, #3578).
|
||||
/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is
|
||||
/// split into its pieces, evenly along its length, and the outline stays as it is.
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 5.0).clamp(0.0, 1e4);
|
||||
let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect();
|
||||
let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count()));
|
||||
let mut lens = lens.iter();
|
||||
if amp == 0.0 {
|
||||
// Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them.
|
||||
let mut subs = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in &path.subpaths {
|
||||
let mut places = vec![];
|
||||
for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) {
|
||||
let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS);
|
||||
let c = sp.segment(i);
|
||||
let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY);
|
||||
if len > 1e-9 && len.is_finite() {
|
||||
places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY))));
|
||||
}
|
||||
}
|
||||
subs.push(insert_at(sp, &places).0);
|
||||
}
|
||||
return PathData::new(subs);
|
||||
}
|
||||
let smooth = smooth_points(p);
|
||||
let seed = seed(p);
|
||||
let mut subs = vec![];
|
||||
@@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// The move and the number of copies of a Transform effect with params `p` that only moves what
|
||||
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
|
||||
/// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales
|
||||
|
||||
@@ -1251,6 +1251,96 @@ fn roughen_smooth_makes_no_cusps() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() {
|
||||
use vectorcraft_geom::AnchorKind;
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
// Two subpaths with lines and curves: a closed one whose last segment is a line, and an open
|
||||
// one that ends with a line.
|
||||
let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0));
|
||||
let mixed = PathData::new(vec![
|
||||
SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::corner(a),
|
||||
vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c),
|
||||
],
|
||||
true,
|
||||
),
|
||||
SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e),
|
||||
vectorcraft_geom::Anchor::corner(f),
|
||||
],
|
||||
false,
|
||||
),
|
||||
]);
|
||||
for path in [square(), ellipse, curve, mixed] {
|
||||
let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect();
|
||||
let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt();
|
||||
for params in
|
||||
[json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})]
|
||||
{
|
||||
let out = run("distort.roughen", params.clone(), &path);
|
||||
assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added");
|
||||
assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}");
|
||||
for (sp, got) in path.subpaths.iter().zip(&out.subpaths) {
|
||||
assert_eq!(got.closed, sp.closed);
|
||||
// Walk the result one original segment at a time: its pieces end on the segment's end anchor.
|
||||
let mut at = 0;
|
||||
for i in 0..sp.segment_count() {
|
||||
let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]);
|
||||
let start = got.anchors[at];
|
||||
let was = sp.anchors[i];
|
||||
assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}");
|
||||
let mut pieces = vec![];
|
||||
loop {
|
||||
let piece = got.segment(at);
|
||||
for j in 0..=8 {
|
||||
let q = piece.eval(j as f64 / 8.0);
|
||||
assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q));
|
||||
}
|
||||
pieces.push(piece.arclen(1e-6));
|
||||
at += 1;
|
||||
if piece.p3.distance(end.p) < 1e-9 {
|
||||
break;
|
||||
}
|
||||
// A point the effect added: a corner without handles on a line, a smooth
|
||||
// point whose handles follow the curve.
|
||||
let added = got.anchors[at];
|
||||
if sp.segment_is_line(i) {
|
||||
assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}");
|
||||
} else {
|
||||
assert_eq!(added.kind, AnchorKind::Smooth, "{params}");
|
||||
let (hi, ho) = (added.h_in - added.p, added.h_out - added.p);
|
||||
assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}");
|
||||
}
|
||||
}
|
||||
assert!(pieces.len() > 1, "{params}: segment {i} is split");
|
||||
assert!(close(pieces.iter().sum::<f64>(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}");
|
||||
let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l)));
|
||||
assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}");
|
||||
}
|
||||
assert_eq!(at, got.segment_count(), "{params}");
|
||||
if !sp.closed {
|
||||
let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]);
|
||||
assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_ridge_count_and_amplitude() {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
|
||||
|
||||
@@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() {
|
||||
|
||||
#[test]
|
||||
fn roughen_keeps_the_drawn_look() {
|
||||
// Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the
|
||||
// path's segments.
|
||||
// Its outline is random and the file holds no seed.
|
||||
assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None);
|
||||
assert_eq!(read_effect("Adobe Roughen", ""), None);
|
||||
}
|
||||
|
||||
@@ -182,7 +182,19 @@ pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet<us
|
||||
out.anchors.push(a);
|
||||
out.anchors.extend(added.remove(&i).unwrap_or_default());
|
||||
}
|
||||
let added_before = |j: usize| inner.range(..j).map(|(_, v)| v.len()).sum::<usize>();
|
||||
// How many anchors were added before each anchor, counted in one pass for all of them.
|
||||
let mut before = vec![0usize; n + 1];
|
||||
for (&seg, ts) in &inner {
|
||||
if let Some(b) = before.get_mut(seg + 1) {
|
||||
*b += ts.len();
|
||||
}
|
||||
}
|
||||
let mut sum = 0;
|
||||
for b in &mut before {
|
||||
sum += *b;
|
||||
*b = sum;
|
||||
}
|
||||
let added_before = |j: usize| before.get(j).copied().unwrap_or(0);
|
||||
let mut marks: BTreeSet<usize> = at_anchor.into_iter().map(|j| j + added_before(j)).collect();
|
||||
for (&seg, ts) in &inner {
|
||||
let base = seg + added_before(seg);
|
||||
|
||||
@@ -602,6 +602,7 @@ since 2026-10-01.
|
||||
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
|
||||
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
|
||||
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.
|
||||
- **Roughen at Size 0 keeps the outline:** with Size 0 (relative or absolute), Effect › Distort & Transform › Roughen… moves no point: each segment is split at the Detail spacing (fewer points past the anchor limit) into pieces of equal length on the segment itself, so the path keeps its outline, with Smooth or Corner points alike. A straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. Before, the points were joined by straight lines (Corner) or a smoothed curve through them (Smooth), which changed curved outlines. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule; what Roughen does is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `tests::roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly`). Scores and hours unchanged.
|
||||
|
||||
- **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged.
|
||||
- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`.
|
||||
|
||||
Reference in New Issue
Block a user