diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index e449151d89..8f9933c40c 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -4,7 +4,8 @@ use kurbo::{Point, Rect, Vec2}; -use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; +use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop}; +use crate::Color; /// Most runs of the stops [`Gradient::padded_over`] writes. pub const MAX_SPREAD_RUNS: usize = 256; @@ -33,8 +34,8 @@ pub struct Padded { pub first: f64, /// How many runs are written, evenly spaced from [`Self::first`] to 1. pub runs: usize, - /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows), - /// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed, + /// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. pub capped: bool, } @@ -50,7 +51,9 @@ impl Gradient { self.padded_within(geom, area, MAX_SPREAD_STOPS) } - /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs + /// written need more stops than that, each run leaves out the stops that change its colors + /// least ([`fit_run`]), so every run is still written. pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option { let kind = self.kind; if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { @@ -68,8 +71,8 @@ impl Gradient { // smallest 0 when the area holds the rings' center, else on its edge. let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); - let one = whole_run(&self.stops); - let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let most = MAX_SPREAD_RUNS.min(budget / 2).max(1); let capped = hi - lo > most as f64; // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) // those around the vector's start where the area reaches it, else the area's runs nearest @@ -79,6 +82,7 @@ impl Gradient { _ => lo, }; let n = (hi - lo).min(most as f64).max(1.0); + let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method); // A radial or diamond writes its runs among the levels from 0, which its stops' offsets // (single-precision fractions of the outermost level) tell apart only so far out, the // nearer the closer its stops lie: past that, the runs end there and the gradient pads. @@ -112,8 +116,9 @@ impl Gradient { self.unrolled_within(MAX_SPREAD_STOPS) } - /// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs - /// that fit, the last one pads. + /// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops + /// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with + /// fewer than two stops for each run do the runs past those that fit pad with the last one. pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { use std::borrow::Cow; if !self.is_spread() { @@ -123,13 +128,29 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } - let one = whole_run(&self.stops); let n = self.repeat_count() as usize; - let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let kept = n.min(budget / 2).max(1); + let one = fit_run(whole_run(&self.stops), budget / kept, self.method); let stops = self.runs(&one, false, 0.0, kept, n as f64); Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) } + /// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two): + /// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that + /// pads with more stops than that without the stops that change its colors least + /// ([`fit_run`]). Others as they are. + pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if self.is_spread() { + return self.unrolled_within(max_stops); + } + if self.stops.len() <= max_stops.max(2) { + return Cow::Borrowed(self); + } + Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() }) + } + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and @@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec { out } +/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two): +/// as it is when it fits, else without the stops that change its colors least, left out one at a +/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still +/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or +/// process plates lie from the line between theirs (a whole unit when its swatch link or color +/// model differs from either of theirs, and a whole unit more when its midpoint or the one before +/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose +/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm. +fn fit_run(one: Vec, budget: usize, method: GradientMethod) -> Vec { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let budget = budget.max(2); + let n = one.len(); + if n <= budget { + return one; + } + let mix = |x: &Color, y: &Color, v: f32| match method { + GradientMethod::Classic => x.lerp_keep_model(y, v), + m => m.mix(x, y, v), + }; + let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 { + let span = b.offset - a.offset; + let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 }; + // Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`. + let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb()); + let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max); + let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs(); + // Process inks, which colors that look alike can print differently: off their line, or a + // change of color model. + let process = match (&a.color, &s.color, &b.color) { + (Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => { + [(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max) + } + _ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0, + _ => 1.0, + }; + let color = color.max(process); + // Separations: a change of swatch link, or of tint or process plates off their line. + let ink = if s.swatch != a.swatch || s.swatch != b.swatch { + 1.0 + } else { + let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs(); + let plates = match (a.plates, s.plates, b.plates) { + (None, None, None) => 0.0, + (Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max), + _ => 1.0, + }; + tint.max(plates) + }; + // Leaving it out merges two spans, and an off-center midpoint shapes one of them. + let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 }; + // Non-negative, so the bits order as the numbers do (NaN counts as 0). + (color.max(opacity).max(ink) + shaped).max(0.0).to_bits() + }; + // The stops still kept on either side of each stop, and each stop's latest cost entry. + let mut prev: Vec = (0..n).map(|i| i.saturating_sub(1)).collect(); + let mut next: Vec = (1..=n).collect(); + let mut gone = vec![false; n]; + let mut version = vec![0u32; n]; + let mut heap: BinaryHeap> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect(); + let mut left = n; + while left > budget { + let Some(Reverse((_, i, v))) = heap.pop() else { break }; + if gone[i] || v != version[i] { + continue; + } + gone[i] = true; + left -= 1; + let (a, b) = (prev[i], next[i]); + next[a] = b; + prev[b] = a; + for j in [a, b] { + if j != 0 && j != n - 1 { + version[j] += 1; + heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j]))); + } + } + } + one.into_iter() + .enumerate() + .filter(|(i, _)| !gone[*i]) + .map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } }) + .collect() +} + /// A run mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(run: &[GradientStop]) -> Vec { let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index 2465039ca5..a88319ccaa 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { } } -/// A run of the stops far too short for the area, or a gradient of many stops, is written with at -/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without -/// stops have nothing to pad. +/// A run of the stops far too short for the area is written with at most so many runs, and pads +/// past them; a gradient of many stops is written with every run the area needs, each run +/// without the stops that change its colors least, within so many stops; a conic, a padding +/// gradient and one without stops have nothing to pad. #[test] -fn far_repeats_and_many_stops_are_capped() { +fn far_repeats_are_capped_and_many_stops_are_sampled() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); @@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() { let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); - assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len()); + for x in [0.7, 33.3, 66.1, 99.5] { + let q = Point::new(x, 0.5); + let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); @@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() { assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); } +/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run +/// when unrolled, each run without the stops that change its colors least: its last run ends on +/// its last color instead of the rest padding with it. +#[test] +fn unrolled_many_stops_keep_every_run() { + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) }; + let u = g.unrolled(); + assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len()); + for t in [0.005, 0.5051, 0.9149, 0.9951] { + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = u.sample(t as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}"); + } + // A padding gradient fitted to fewer stops keeps its ends. + let pad = Gradient::new(GradientKind::Linear, g.stops.clone()); + let fitted = pad.fitted(16); + assert_eq!(fitted.stops.len(), 16); + for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] { + assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color); + } + assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_))); +} + +/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1) +/// is written within the limit, every run the area needs included. +#[test] +fn a_run_over_the_stop_limit_is_written_within_it() { + let stops: Vec = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap(); + assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len()); + assert!(g.unrolled_within(256).stops.len() <= 256); +} + +/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a +/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose +/// them. +#[test] +fn fitting_keeps_sharp_steps_and_swatch_links() { + let mut stops: Vec = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect(); + let red = Color::rgb(1.0, 0.0, 0.0); + // A band of red from 0.5003 to 0.5013, with a sharp step at each side. + for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] { + let gray = Color::gray(offset); + stops.push(GradientStop::new(offset, color.unwrap_or(gray))); + } + let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) }; + stops.push(spot.clone()); + // Three stops of one ink that look alike but print at different tints. + for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] { + stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) }); + } + // A span shaped by an off-center midpoint. + if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) { + s.midpoint = 0.13; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let g = Gradient::new(GradientKind::Linear, stops); + let fitted = g.fitted(32); + assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len()); + let red_at = |t: f32| fitted.sample(t).0.to_rgb(); + assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays"); + assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow"); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays"); + assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays"); + // Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the + // first stays. (The stop before an ink starts stays as well: leaving it out would blend the + // ink in sooner.) + let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) }; + let few = vec![ + GradientStop::new(0.0, Color::gray(0.0)), + GradientStop::new(0.2, Color::gray(0.17)), + GradientStop::new(0.3, Color::gray(0.3)), + ink(0.4, 0.2), + ink(0.5, 0.9), + ink(0.6, 0.2), + GradientStop::new(1.0, Color::gray(1.0)), + ]; + let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]); + // A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line + // gives. + let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5)); + let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5); + let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)]; + let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) }; + let fitted = perceptual.fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + // A black of other inks between blacks that look the same prints differently: it stays. + let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 }); + let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 }); + let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)]; + let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() }; + let u = spread.unrolled_within(128); + assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run"); +} + /// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and /// PDF write as images) repeat with the stops, for each kind. #[test] diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index ec54be20cd..ffd128b6dd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -32,6 +32,12 @@ const SHARP: f64 = 1.25; const HAIRPIN: f64 = 4.0; /// Rows of a mesh across the stroke (more where the gradient has stops). const MESH_ROWS: usize = 16; +/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at +/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out). +pub(crate) const MAX_MESH_STOPS: usize = 1024; +/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more: +/// a subpath with more ribs than leave room for a row at each stop becomes several meshes. +pub(crate) const MAX_MESH_POINTS: usize = 1 << 20; /// The most of a subpath one column of a mesh along the stroke spans. const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. @@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec { /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the -/// stroke's outline), coloured as [`gradient_slices`] paint it. +/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to +/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is +/// split into meshes of fewer ribs ([`rib_pieces`]). pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { - let g = &*g.unrolled(); + let fitted = g.fitted(MAX_MESH_STOPS); let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); - let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec = if across { vec![] } else { at_stops.clone() }; + let marks: Vec = if across { vec![] } else { mesh_marks(&fitted) }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let us = &us; + let g = &*fitted; + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + // A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than + // that leaves room for is split into meshes of fewer ribs. + let per_mesh = MAX_MESH_POINTS / (us.len() + 2); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) + .flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec())) .map(|ribs| { let (rows, cols) = (us.len() + 1, ribs.len() - 1); // Each level across: a point on each rib and the gradient position there. @@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }) .collect() } + +/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip: +/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before, +/// so the meshes meet without a gap. +pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec> { + let per_mesh = per_mesh.max(2); + (0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect() +} diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 3745e4a6f6..784c84d1db 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { } } +/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100 +/// times: each mesh holds at most so many points, and every run still starts on its first color. +#[test] +fn a_mesh_across_many_ribs_and_stops_stays_bounded() { + use vectorcraft_color::GradientSpread; + let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect(); + let bp = poly(&pts, false); + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) }; + let mut st = stroke(4.0, StrokeGradientMode::Across); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g); + assert!(!meshes.is_empty()); + for m in &meshes { + assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len()); + // A row at the start of each of the 100 runs, on every rib, is white. + let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count(); + assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len()); + } + // A strip of more ribs than one mesh has room for is split into meshes that share their end + // ribs, every rib in one of them. + use super::gradient::rib_pieces; + assert_eq!(rib_pieces(5, 3), [0..3, 2..5]); + assert_eq!(rib_pieces(4, 3), [0..3, 2..4]); + let one = rib_pieces(2, 1000); + assert_eq!((one.len(), one.first()), (1, Some(&(0..2)))); + assert_eq!(rib_pieces(3, 0), [0..2, 1..3]); +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 4e7e1fa658..6d4bcd54d9 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -194,10 +194,12 @@ impl Expandable { NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), _ => shape && !c.appearance.stroke_paint().is_none(), }; - e.gradient |= match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), - _ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()), - }; + // Once one is found, the next path is not measured. + e.gradient = e.gradient + || match &c.kind { + NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), + _ => shape && gradient_expands(d, c), + }; e.fill |= e.gradient; e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())), @@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> { } } +/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or +/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a +/// path that live-effect plug-ins paint, whether a piece of the art they paint has one +/// ([`expand_pieces`]). +fn gradient_expands(d: &Document, l: &Node) -> bool { + if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) { + return false; + } + if !vectorcraft_render::effects::has_painted_effects(l) { + return piece_gradient_expands(d, l); + } + let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false }; + let mut any = false; + art.walk(&mut |c| { + any = any + || (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && !c.is_ruler_guide() + && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()) + && piece_gradient_expands(d, c)); + }); + any +} + +/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]). +fn piece_gradient_expands(d: &Document, l: &Node) -> bool { + if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) { + return l.appearance.items.iter().any(|i| gradient_fill(i).is_some()); + } + let reshaped = reshaped(d, l); + let l = reshaped.as_ref().unwrap_or(l); + let Some((path, _)) = node_path(l) else { return false }; + let Some(bounds) = path.bounds() else { return false }; + l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| { + fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some())) + }) +} + /// The definition and placement of a visible pattern fill whose pattern `d` defines. fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> { match i { @@ -476,8 +515,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?; vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))] } else { - // A gradient that repeats or reflects expands as its stops repeated over the box. - let (g, runs) = padded_on(g, bounds); + // A gradient that repeats or reflects expands as its stops repeated over the box (and + // stays a gradient when the box needs more runs than are written). + let (g, runs) = padded_on(g, bounds)?; let g = &*g; let frame = Frame::new(g, bounds); match to { @@ -1244,26 +1284,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32)) } +/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and +/// how many runs lie evenly from there to 1. +type Runs = Option<(f64, usize)>; + /// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond /// gradient that repeats or reflects with its stops repeated as many times as the box needs /// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the /// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated /// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from -/// there to 1. -fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) { +/// there to 1. `None` when the box needs more runs than are written +/// ([`vectorcraft_color::Padded::capped`]): the gradient stays. +fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> { use std::borrow::Cow; if !g.gradient.is_spread() { - return (Cow::Borrowed(g), None); + return Some((Cow::Borrowed(g), None)); } let geom = g.resolve(bounds); match g.gradient.padded_over(&geom, bounds) { + Some(p) if p.capped => None, Some(p) => { let runs = Some((p.first, p.runs)); - (Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs) + Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)) } None => { let runs = Some((0.0, g.gradient.repeat_count() as usize)); - (Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs) + Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)) } } } diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 081db4cca0..07589a2f4a 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -529,6 +529,42 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() { } } +/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not +/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of +/// the object (its stroke) still expands. `object.expand.info` reports no gradient or fill to +/// expand, and Expand with only Fill on reports nothing to expand; the same gradient in 70 runs +/// (10 repeats) reports both. +#[test] +fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { + let gradient = |repeats: u32| { + json!({"kind": "linear", "spread": "repeat", "repeats": repeats, "start": [30, 100], "end": [50, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}) + }; + for (repeats, expands) in [(10, true), (100, false)] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(repeats), true); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!( + (info["gradient"].clone(), info["fill"].clone(), info["stroke"].clone()), + (json!(expands), json!(expands), json!(true)), + "{repeats}" + ); + let only_fill = s.execute("object.expand", &json!({"object": false, "stroke": false})); + assert_eq!(only_fill.is_ok(), expands, "{repeats}: {only_fill:?}"); + } + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(100), true); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{to}: {}", mean_diff(&before, &after)); + let mut gradients = 0; + s.doc().unwrap().doc.walk(|n| gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_)))); + assert_eq!(gradients, 1, "{to}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 6357e81eaf..75580fb6bc 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -722,6 +722,27 @@ fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() { } } +/// `object.expand.info` measures each piece a painted effect returns: a piece whose repeating +/// gradient needs more runs across it than are written (1,000) reports no gradient to expand, as +/// Expand leaves it a gradient, and the same gradient in 10 runs reports one. +#[test] +fn expand_info_measures_the_gradient_on_each_painted_piece() { + for (end, expands) in [(120.0, true), (100.2, false)] { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let gradient = json!({"kind": "linear", "spread": "repeat", "repeats": 10, "start": [100, 50], "end": [end, 50]}); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": gradient})); + let out = json!({"objects": [{"path": square_at(100.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.expandruns", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandruns"})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{end}"); + let only_fill = s.execute("object.expand", &json!({"object": false, "fill": true, "stroke": false})); + assert_eq!(only_fill.is_ok(), expands, "{end}: {only_fill:?}"); + } +} + /// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its /// tiles expanded, and a piece with a paint of its own keeps that paint. #[test] diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 79d4d52c11..f7e067aa30 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -206,6 +206,14 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { p } +/// The decimals, at least `least` and at most 12, for numbers that place a period of length +/// `period` repeated out to `reach` (in the same units): each period then rounds by at most +/// 10^-decimals, and over the `reach / period` periods that adds up to at most a hundredth of one. +fn period_decimals(period: f64, reach: f64, least: u8) -> u8 { + let d = (100.0 * reach / (period * period)).log10().ceil(); + if d.is_nan() || d == f64::INFINITY { 12 } else { d.clamp(f64::from(least), 12.0) as u8 } +} + /// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`. fn color_css(c: &Color, opacity: f32) -> String { if opacity >= 1.0 { @@ -247,13 +255,22 @@ impl CssUnits { /// `points` in these units (`0` without a unit). fn len(self, points: f64) -> String { - let per_point = match self { + self.len_to(points, 3) + } + + /// One point in these units. + fn per_point(self) -> f64 { + match self { CssUnits::Px | CssUnits::Pt => 1.0, CssUnits::Mm => 25.4 / 72.0, CssUnits::Cm => 2.54 / 72.0, CssUnits::In => 1.0 / 72.0, - }; - match fmt_num(points * per_point, 3) { + } + } + + /// [`Self::len`] with `decimals` decimals. + fn len_to(self, points: f64, decimals: u8) -> String { + match fmt_num(points * self.per_point(), decimals) { zero if zero == "0" => zero, v => format!("{v}{}", self.name()), } @@ -582,9 +599,21 @@ impl Rules<'_> { } _ => (unrolled, geom, ""), }; - let stops = |pos: &dyn Fn(f32) -> f64| { + // A repeating gradient's period is the span from its first stop to its last, repeated + // across the box (from 0% to `reach`%): its positions get at least four decimals, and more + // when the box holds so many periods that their rounding would add up to more than a + // hundredth of one ([`period_decimals`]). + let span = gradient.expanded().next().zip(gradient.expanded().last()).map(|(a, z)| (a.0, z.0)); + let decimals = |pos: &dyn Fn(f32) -> f64, reach: f64| match span { + Some((a, z)) if !repeating.is_empty() => { + let (p0, p1) = (pos(a) * 100.0, pos(z) * 100.0); + period_decimals((p1 - p0).abs(), p0.abs().max((reach - p0).abs()), 4) + } + _ => 2, + }; + let stops = |pos: &dyn Fn(f32) -> f64, decimals: u8| { let stops: Vec = - gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); + gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect(); stops.join(", ") }; match g.gradient.kind { @@ -601,7 +630,7 @@ impl Rules<'_> { let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs(); let from = (geom.start - b.center()).dot(dir); let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 }; - Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) + Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos, decimals(&pos, 100.0)))) } // CSS has no diamond gradient: the ellipse through the square's corners stands in. GradientKind::Radial | GradientKind::Diamond => { @@ -611,13 +640,30 @@ impl Rules<'_> { } else if geom.focal.is_some() { self.warn("radial gradients start at their centre (an off-centre focal point is left out)"); } + // The positions are a share of the radius, and the box reaches out to its farthest + // corner. A repeating gradient's period scales with its radius, so the radius gets + // the decimals that keep the periods in place out to that corner too. + let units = self.opts.units; + let small = r * geom.aspect.abs().min(1.0); + let far = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1)] + .into_iter() + .map(|(x, y)| (Point::new(x, y) - geom.start).hypot()) + .fold(0.0, f64::max); + let reach = if small > 1e-12 { 100.0 * far / small } else { 0.0 }; + let pos = |t: f32| t as f64; + let len = match span { + Some((a, z)) if !repeating.is_empty() => { + period_decimals(small * f64::from(z - a).abs() * units.per_point(), far * units.per_point(), 3) + } + _ => 3, + }; let shape = if (geom.aspect - 1.0).abs() < 1e-9 { - format!("circle {}", self.len(r)) + format!("circle {}", units.len_to(r, len)) } else { - format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) + format!("ellipse {} {}", units.len_to(r, len), units.len_to(r * geom.aspect, len)) }; let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0)); - Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) + Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&pos, decimals(&pos, reach)))) } GradientKind::Conic => { // CSS sweeps clockwise from the `from` angle, 0deg straight up diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 2ca6dcc30b..8bf50d4add 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -359,6 +359,28 @@ fn spread_gradients_give_repeating_css_gradients() { css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(), Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)") ); + // A period that is not a whole hundredth of the box keeps four decimals, so it does not drift + // across the box. + let short = GradientGeom { end: Point::new(10.3452, 30.0), ..across }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)") + ); + // About 1,000 periods across the box: six decimals, so their rounding adds up to at most a + // hundredth of a period at the far side (with four, `0.1%` would be almost half a period out + // there). + let tiny = GradientGeom { end: Point::new(0.400196, 30.0), ..across }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, tiny).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 0.100049%)") + ); + // A radial period of 0.03085 px out to a corner 67 px away: its radius keeps the decimals that + // hold the periods in place there. + let dot = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.1234, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Radial, GradientSpread::Repeat, dot).as_deref(), + Some("repeating-radial-gradient(circle 0.03085px at 40px 30px, #ffffff 0%, #000000 100%)") + ); let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; assert_eq!( css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 46fde93aad..826e7e02f6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -759,6 +759,39 @@ mod gradient_chip_tests { } } + /// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip) + /// still shows its runs: the grid gets more cells than runs, so its points do not all sample + /// the same place in a run. + #[test] + fn a_chip_repeated_once_a_cell_is_not_one_color() { + for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] { + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = spread; + gp.gradient.repeats = 8; + let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0); + assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}"); + let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows); + let row: std::collections::HashSet = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{spread:?}: {row:?}"); + } + // A gradient that pads keeps a cell about every 3 points. + assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8)); + // Many runs give a small chip at most a cell a point. + let mut many = paint(GradientKind::Linear, 0.0).gradient; + many.spread = vectorcraft_color::GradientSpread::Repeat; + many.repeats = 100; + assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12)); + assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128)); + // A count that matches a chip's points still shows its runs. + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat; + gp.gradient.repeats = 23; + let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0); + let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows); + let row: std::collections::HashSet = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{cols}: {row:?}"); + } + #[test] fn radial_chip_runs_from_the_centre_out() { let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4); diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 87c6034bda..1b814728a1 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -33,11 +33,7 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient if !(rect.width() > 0.0 && rect.height() > 0.0) { return; } - // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). - let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; - let max = if freeform { 16 } else { 64 }; - let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); - let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let (cols, rows) = chip_grid(&gradient.gradient, rect.width(), rect.height()); let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); // Stops below full opacity show a checkerboard through them. if colors.iter().any(|c| c.a() < u8::MAX) { @@ -59,6 +55,38 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient ui.painter().add(egui::Shape::mesh(mesh)); } +/// Most grid cells across (and down) a chip of a gradient that repeats or reflects. +const SPREAD_CHIP_CELLS: usize = 128; + +/// The grid (columns, rows) a `width` x `height` chip of gradient `g` is sampled on: a cell about +/// every 3 points (at most 64 a side, 16 for a freeform gradient, whose sampling is costly per +/// spot; the mesh interpolates between them), and for a gradient that repeats or reflects at +/// least 4 cells a run along each side (at most one a point, and [`SPREAD_CHIP_CELLS`]), so the +/// grid's points do not all fall at the same place in a run and paint the chip one color. For +/// such a gradient a side gets a cell or two more when its points would fall at fewer than three +/// places in a run. +pub(super) fn chip_grid(g: &vectorcraft_color::Gradient, width: f32, height: f32) -> (usize, usize) { + let freeform = g.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let runs = if g.is_spread() { g.repeat_count() as usize } else { 1 }; + let least = if runs > 1 { (4 * runs).min(SPREAD_CHIP_CELLS) } else { 1 }; + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + let side = |len: f32| { + let mut cells = ((len / 3.0).ceil() as usize).clamp(1, max).max(least.min(len.ceil() as usize)); + // Points `i / cells` of the way along fall at `cells / gcd(cells, runs)` places in a run. + while runs > 1 && cells / gcd(cells, runs) < 3 { + cells += 1; + } + cells + }; + (side(width), side(height)) +} + /// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side /// (3 to 8 points). fn checkerboard(ui: &Ui, rect: Rect) { diff --git a/docs/mcp.md b/docs/mcp.md index 84ce94f85a..4f70018100 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1270,11 +1270,19 @@ is written with its stops repeated as many times as the shape needs: at most 256 legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning. The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with -a warning. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, by halving -the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis, with at -most 16,384 samples a shading) instead of blurred across the samples around it. Object › Expand expands the -repeated stops, into the same whole number of `steps` objects for each run (at least one, so about `steps` in all), -each run starting on an object's edge: +a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that +change its colors least. Each run keeps its ends; sharp steps and swatch-linked stops are the last to be left out, +but a run with more of them than its share of the limit loses some. The same applies to a gradient along or across a +stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. For CSS the 16,384 +counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual +gradient stops along each span's curve, so the written gradient can hold more. PDF +import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples, +so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples +around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay +blurred, and a color band narrower than 1/64 of the axis can be missed when no sample lands in it. Object › Expand +expands the repeated stops, into the same whole number of `steps` objects for each run (at least one, so about +`steps` in all), each run starting on an object's edge; a gradient that needs more than 256 runs across the box stays +a gradient: ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}}