From 91d705e155cee50aff6c4a4738c5a653694588c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:18:57 -0800 Subject: [PATCH] Gradients: repeat or reflect a gradient's stops a set number of times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A linear, radial, conic or diamond gradient has a spread: Pad (the default, as before), Repeat or Reflect, with a count of 1 to 100 (VectorCraft's own limit). Repeat and Reflect run the stops that many times along the vector (around the turn for a conic) and keep going past both ends, Reflect running every other run backwards, as SVG's spreadMethod does. The Gradient panel has a Spread menu and a Repeats field, and the Gradient tool marks where the runs meet. paint.setFill and paint.editGradient take `spread` and `repeats`; bad values are errors. Presets, swatches, blends, strokes along or across a path, Outline Stroke and Object › Expand follow the spread; Expand gives each run the same whole number of objects, and Outline Stroke's mesh keeps the color on each side of a sharp step between two stops at one offset. SVG writes and reads spreadMethod: SVG import now reads a repeating or reflecting gradient as one that repeats or reflects once, instead of expanding its stops over the shape. CSS writes repeating gradients. PDF writes a conic's sweep shading over one run. PDF, EPS, legacy .ai and the .ai editing data write the other kinds with their stops repeated across the shape: at most 256 runs and 16,384 stops (256 stops in .ai files), and for a radial or diamond no farther than 65,536 runs from its center, with a warning past those. PDF import now finds sharp steps in any axial or radial shading instead of blurring them. Docs: docs/mcp.md (Spread), docs/target-app-parity.md, docs/gaps.md. --- crates/color/src/gradient.rs | 182 +++++++++- crates/color/src/gradient/spread.rs | 268 ++++++++++++++ crates/color/src/gradient/spread/tests.rs | 368 ++++++++++++++++++++ crates/color/src/lib.rs | 2 +- crates/color/src/palette_io.rs | 4 +- crates/color/src/palette_io/tests.rs | 23 ++ crates/doc/src/blend.rs | 24 ++ crates/effects/src/stroke/gradient.rs | 48 ++- crates/effects/src/stroke/tests_gradient.rs | 59 ++++ crates/engine/src/cmd/expand.rs | 107 ++++-- crates/engine/src/cmd/gradient.rs | 57 ++- crates/engine/src/cmd/gradpresets.rs | 18 +- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/swatch.rs | 11 +- crates/engine/src/tests/gradient.rs | 131 +++++++ crates/engine/src/tests/objexpand.rs | 44 +++ crates/eps/src/ai_editing/tests.rs | 141 ++++++++ crates/eps/src/ai_editing/writer.rs | 4 +- crates/eps/src/ai_editing/writer/paints.rs | 60 +++- crates/eps/src/legacy_ai.rs | 2 + crates/eps/src/legacy_ai/encoding.rs | 8 +- crates/eps/src/legacy_ai/tests.rs | 139 ++++++++ crates/eps/src/legacy_ai/writer.rs | 23 +- crates/eps/src/ps.rs | 32 +- crates/eps/src/scene.rs | 46 ++- crates/eps/src/tests.rs | 76 ++++ crates/metafile/src/scene.rs | 4 +- crates/pdf/src/export/nodes.rs | 13 +- crates/pdf/src/export/paint.rs | 114 +++++- crates/pdf/src/import_shading.rs | 48 ++- crates/pdf/src/tests.rs | 1 + crates/pdf/src/tests/spread.rs | 260 ++++++++++++++ crates/plugins/src/objects.rs | 1 + crates/render/src/dither.rs | 2 +- crates/render/src/paint.rs | 18 +- crates/render/src/renderer/paint.rs | 3 +- crates/render/src/tests/strokegradient.rs | 12 + crates/render/tests/it/main.rs | 1 + crates/render/tests/it/spread.rs | 93 +++++ crates/svg/src/css.rs | 26 +- crates/svg/src/export/paint.rs | 19 +- crates/svg/src/import.rs | 1 - crates/svg/src/import/importer.rs | 53 +-- crates/svg/src/import/spread.rs | 154 -------- crates/svg/src/lib.rs | 4 +- crates/svg/tests/it/css_rules.rs | 39 +++ crates/svg/tests/it/main.rs | 1 + crates/svg/tests/it/spread.rs | 132 +++++++ crates/tools/src/params.rs | 8 + crates/tools/src/xform/eyedropper.rs | 2 +- crates/tools/src/xform/gradient.rs | 28 ++ crates/tools/src/xform/gradient/tests.rs | 33 ++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/gradient.rs | 38 +- crates/ui-egui/src/panels/gradient/tests.rs | 38 ++ crates/ui-egui/src/widgets/chips.rs | 2 +- docs/gaps.md | 6 +- docs/mcp.md | 37 +- docs/target-app-parity.md | 1 + 85 files changed, 2874 insertions(+), 317 deletions(-) create mode 100644 crates/color/src/gradient/spread.rs create mode 100644 crates/color/src/gradient/spread/tests.rs create mode 100644 crates/pdf/src/tests/spread.rs create mode 100644 crates/render/tests/it/spread.rs delete mode 100644 crates/svg/src/import/spread.rs create mode 100644 crates/svg/tests/it/spread.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5803e3224..e49478eab7 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; use crate::Color; use crate::freeform::Freeform; +mod spread; +pub use spread::{MAX_SPREAD_LEVEL, MAX_SPREAD_RUNS, MAX_SPREAD_STOPS, Padded}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientKind { #[default] @@ -94,6 +97,82 @@ impl GradientMethod { } } +/// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the +/// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times +/// along the vector (around the circle, for a conic), and keep going past both of its ends. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)] +pub enum GradientSpread { + /// The first and last stops' colors carry on past the ends, as gradients always did. + #[default] + Pad, + /// The stops start again at each end. + Repeat, + /// The stops run back the other way at each end, so every other run is mirrored. + Reflect, +} + +impl GradientSpread { + /// Every spread, in the order the Gradient panel lists them. + pub const ALL: [GradientSpread; 3] = [GradientSpread::Pad, GradientSpread::Repeat, GradientSpread::Reflect]; + /// Parse a spread name (`pad`, `repeat`, `reflect`; any case). + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + Self::Pad => "Pad", + Self::Repeat => "Repeat", + Self::Reflect => "Reflect", + } + } + pub fn is_pad(&self) -> bool { + *self == Self::Pad + } +} + +impl<'de> Deserialize<'de> for GradientSpread { + /// A spread name; a file's unknown name or other value reads as Pad. + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Name(String), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Name(s) => Self::parse(&s).unwrap_or_default(), + Raw::Other(_) => Self::Pad, + }) + } +} + +/// Most times a gradient's stops run along its vector ([`Gradient::repeats`]): VectorCraft's own +/// limit. +pub const MAX_REPEATS: u32 = 100; + +fn one_repeat() -> u32 { + 1 +} + +fn is_one_repeat(n: &u32) -> bool { + *n == 1 +} + +/// A file's repeat count: a number, rounded and kept within 1 to [`MAX_REPEATS`]; anything else +/// reads as 1. +fn lenient_repeats<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Number(f64), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Number(n) if n.is_finite() => n.round().clamp(1.0, f64::from(MAX_REPEATS)) as u32, + _ => 1, + }) +} + /// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring /// expanded stops may stray from the perceptual mix: under half a colour level. const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; @@ -259,6 +338,15 @@ pub struct Gradient { /// How the colours between the stops are mixed (Classic unless set; written only when not). #[serde(default, skip_serializing_if = "GradientMethod::is_classic")] pub method: GradientMethod, + /// What it paints past the ends of its stops (Pad unless set; written only when not). A + /// freeform gradient has no ends and ignores it. + #[serde(default, skip_serializing_if = "GradientSpread::is_pad")] + pub spread: GradientSpread, + /// How many times the stops run along the vector with Repeat and Reflect, 1 to + /// [`MAX_REPEATS`] (use [`Self::repeat_count`]: a value set in code may be out of range); Pad + /// ignores it. Written only when it is not 1. + #[serde(default = "one_repeat", skip_serializing_if = "is_one_repeat", deserialize_with = "lenient_repeats")] + pub repeats: u32, } impl Default for Gradient { @@ -271,7 +359,55 @@ impl Default for Gradient { impl Gradient { /// A gradient of `kind` through `stops`, with the default options. pub fn new(kind: GradientKind, stops: Vec) -> Self { - Self { kind, stops, dither: false, method: GradientMethod::Classic } + Self { kind, stops, dither: false, method: GradientMethod::Classic, spread: GradientSpread::Pad, repeats: 1 } + } + /// [`Self::repeats`] kept within 1 to [`MAX_REPEATS`]. + pub fn repeat_count(&self) -> u32 { + self.repeats.clamp(1, MAX_REPEATS) + } + /// Does it paint something other than its end colors past the ends of its stops, or run its + /// stops more than once: a linear, radial, conic or diamond gradient that repeats or reflects? + pub fn is_spread(&self) -> bool { + !self.spread.is_pad() && self.kind != GradientKind::Freeform + } + /// The spread and count as the gradient paints them: Pad and 1 for one that pads (whatever + /// count it keeps) and for a freeform one, else its spread and [`Self::repeat_count`]. Equal + /// for two gradients whose spreads paint alike. + pub fn painted_spread(&self) -> (GradientSpread, u32) { + if self.is_spread() { (self.spread, self.repeat_count()) } else { (GradientSpread::Pad, 1) } + } + /// The position (0..1) along the stops that gradient parameter `t` (0 at the start of the + /// vector, 1 at its end; [`GradientGeom::param_at`]) samples: `t` itself with Pad (the stops + /// clamp it), else `t` × [`Self::repeat_count`] wrapped back into 0..1, every other run + /// mirrored with Reflect. A `t` that is not finite is returned as it is. + pub fn spread_param(&self, t: f64) -> f64 { + self.spreader()(t) + } + /// How many wedges a conic gradient is written as ([`GradientGeom::conic_wedges`]) where + /// `per_turn` would do for stops that run once around: that many for each time the stops run + /// around, at most [`MAX_CONIC_WEDGES`]. Sample each wedge at [`Self::spread_param`] of its + /// turn. + pub fn conic_wedge_count(&self, per_turn: usize) -> usize { + let runs = if self.is_spread() { self.repeat_count() as usize } else { 1 }; + per_turn.saturating_mul(runs).clamp(1, MAX_CONIC_WEDGES) + } + /// [`Self::spread_param`] as a function that holds no borrow of the gradient. + pub fn spreader(&self) -> impl Fn(f64) -> f64 + Copy + use<> { + let spread = if self.is_spread() { self.spread } else { GradientSpread::Pad }; + let n = f64::from(self.repeat_count()); + move |t: f64| { + if !t.is_finite() { + return t; + } + match spread { + GradientSpread::Pad => t, + GradientSpread::Repeat => (t * n).rem_euclid(1.0), + GradientSpread::Reflect => { + let w = (t * n).rem_euclid(2.0); + if w > 1.0 { 2.0 - w } else { w } + } + } + } } /// Colour and opacity at `t` (honours midpoints and the method). pub fn sample(&self, t: f32) -> (Color, f32) { @@ -544,10 +680,18 @@ impl Gradient { GradientStop { opacity, ..GradientStop::new(offset, color) } }; let d = to - from; - let mut stops = vec![stop(0.0, from)]; + let expanded = self.expanded_stops(); + // Where stops share the offset `from` (a sharp step, as where the runs of a repeating + // gradient meet), the piece starts with the color after the step; sampling gives the one + // before it. + let first = match expanded.iter().rev().find(|(t, ..)| *t == from) { + Some(&(_, color, opacity)) if d > 1e-6 => GradientStop { opacity, ..GradientStop::new(0.0, color) }, + _ => stop(0.0, from), + }; + let mut stops = vec![first]; if d > 1e-6 { stops.extend( - self.expanded_stops() + expanded .into_iter() .filter(|(t, ..)| *t > from && *t < to) .map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }), @@ -954,6 +1098,35 @@ impl GradientGeom { self.set_focal(Some(m * q)); } } + /// The placement whose level `t` is this one's level `k`·`t` (`k` > 0), for a linear, radial or + /// diamond gradient: the vector `k` times as long from the same start, and for an off-center + /// radial the extent ellipse scaled `k` times about the focal point, which stays. A conic keeps + /// its placement (its level is a turn). + pub fn levels_scaled(&self, kind: GradientKind, k: f64) -> GradientGeom { + if kind == GradientKind::Conic || !(k.is_finite() && k > 0.0) { + return *self; + } + let u = self.end - self.start; + let mut out = *self; + if kind == GradientKind::Radial + && let (Some(f), Some(frame)) = (self.focal, self.unit_frame()) + { + // Level t of an off-center radial is the extent ellipse scaled by t about the line + // from the focal point to the center: the new center sits on that line, `k` times as + // far from the focal point. + let f = (frame.inverse() * f).to_vec2(); + out.start = frame * (f * (1.0 - k)).to_point(); + } + out.end = out.start + u * k; + out + } + + /// The placement over which one run of the stops of a gradient that repeats `n` times along + /// its vector lies ([`Self::levels_scaled`] by 1/`n`). + pub fn period(&self, kind: GradientKind, n: u32) -> GradientGeom { + self.levels_scaled(kind, 1.0 / f64::from(n.max(1))) + } + pub fn angle_deg(&self) -> f64 { let v = self.end - self.start; (-v.y).atan2(v.x).to_degrees() @@ -1148,12 +1321,13 @@ impl GradientPaint { inks: bool, ) -> impl Iterator + use { let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither); + let spread = self.gradient.spreader(); let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); let (x0, y0) = (area.x0, area.y0); let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end)); down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| { let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch); - let c = ramp.at(geom.param_at(kind, p) as f32); + let c = ramp.at(spread(geom.param_at(kind, p)) as f32); std::array::from_fn(|k| { if k + 1 == N { return (c[k].clamp(0.0, 1.0) * 255.0).round() as u8; diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs new file mode 100644 index 0000000000..e449151d89 --- /dev/null +++ b/crates/color/src/gradient/spread.rs @@ -0,0 +1,268 @@ +//! A repeating or reflecting gradient as one that pads, for formats whose gradients only pad +//! past their ends: the stops repeated (every other run mirrored, for Reflect) as many times as +//! the painted area needs, and the vector stretched over those runs. + +use kurbo::{Point, Rect, Vec2}; + +use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; + +/// Most runs of the stops [`Gradient::padded_over`] writes. +pub const MAX_SPREAD_RUNS: usize = 256; + +/// Most stops [`Gradient::padded_over`] writes, all runs together. +pub const MAX_SPREAD_STOPS: usize = 16384; + +/// The farthest level from its center a radial or diamond gradient's runs are written out to by +/// [`Gradient::padded_over`] (its stops' offsets, single-precision fractions of it, stay apart), +/// nearer for stops closer together than a run ([`MIN_OFFSET_GAP`]). +pub const MAX_SPREAD_LEVEL: f64 = 65536.0; + +/// The least difference between two stops' offsets [`Gradient::padded_over`] writes as +/// different: sixteen times single precision's step just below 1. +const MIN_OFFSET_GAP: f64 = 1e-6; + +/// A repeating or reflecting gradient written as one that pads ([`Gradient::padded_over`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Padded { + /// The gradient with its stops repeated, padding past its ends. + pub gradient: Gradient, + /// Where those runs lie. + pub geom: GradientGeom, + /// Where along `geom` (0 to 1) the first run written starts: 0 for a linear gradient, the + /// first run's level over the last one's outer level for a radial or diamond one. + 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. + pub capped: bool, +} + +impl Gradient { + /// A linear, radial or diamond gradient that repeats or reflects, placed at `geom`, as a + /// gradient that pads and paints `area` the same way: its stops repeated, each run's + /// placement [`GradientGeom::period`] of `geom`, as many runs as the area reaches (a radial or + /// diamond from its first level, 0, the first stop's color inside the area's innermost run). + /// `None` for a gradient that pads, a conic or + /// freeform one, one without stops, and a placement whose levels at the corners are not + /// finite. + pub fn padded_over(&self, geom: &GradientGeom, area: Rect) -> Option { + self.padded_within(geom, area, MAX_SPREAD_STOPS) + } + + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + 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() { + return None; + } + let period = geom.period(kind, self.repeat_count()); + let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; + let ts = corners.map(|p| period.param_at(kind, p)); + if ts.iter().any(|t| !t.is_finite()) { + return None; + } + let centered = kind.is_centered(); + // The level's range over the area. A linear gradient's extremes lie at corners. Radial and + // diamond levels are nested convex rings from level 0: the largest at a corner, the + // 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 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 + // to it. + let lo = match capped { + true if !centered => (-((most / 2) as f64)).clamp(lo, hi - most as f64), + _ => lo, + }; + let n = (hi - lo).min(most as f64).max(1.0); + // 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. + let gap = one.windows(2).map(|w| f64::from(w[1].offset - w[0].offset)).filter(|d| *d > 0.0).fold(1.0, f64::min); + // A whole level, so the runs keep their places (and Reflect its mirrored ones). + let farthest = MAX_SPREAD_LEVEL.min(gap / MIN_OFFSET_GAP).floor().max(n); + let far = centered && lo + n > farthest; + let (lo, capped) = if far { ((farthest - n).max(0.0), true) } else { (lo, capped) }; + // `lo` is a whole number, possibly past what an integer holds: its parity in floating point. + let first_odd = lo.rem_euclid(2.0) >= 1.0; + let (stops, geom) = if centered { + // The levels from 0, the runs from level `lo` (the first stop's color inside it). + (self.runs(&one, first_odd, lo, n as usize, lo + n), period.levels_scaled(kind, lo + n)) + } else { + let u = period.end - period.start; + ( + self.runs(&one, first_odd, 0.0, n as usize, n), + GradientGeom { start: period.start + u * lo, end: period.start + u * (lo + n), ..period }, + ) + }; + let gradient = Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }; + let first = if centered { lo / (lo + n) } else { 0.0 }; + Some(Padded { gradient, geom, first, runs: n as usize, capped }) + } + + /// The gradient as it runs from the start of its vector (0) to its end (1), as one that + /// pads: a gradient that repeats or reflects with its stops repeated [`Self::repeat_count`] + /// times between 0 and 1 (every other run mirrored, for Reflect), for what only shows that + /// stretch (a gradient along or across a stroke, a swatch's preview). Others as they are. + pub fn unrolled(&self) -> std::borrow::Cow<'_, 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. + pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if !self.is_spread() { + return Cow::Borrowed(self); + } + // Without stops there is nothing to repeat: it pads all the same. + 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 stops = self.runs(&one, false, 0.0, kept, n as f64); + Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..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 + /// one without stops. + pub fn cycle(&self) -> Option<(Gradient, u32)> { + if !self.is_spread() || self.stops.is_empty() { + return None; + } + let one = whole_run(&self.stops); + let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 }; + let stops = self.runs(&one, false, 0.0, runs, runs as f64); + Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32)) + } + + /// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1, + /// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with + /// Reflect, the odd-numbered runs are mirrored). + fn runs(&self, one: &[GradientStop], first_odd: bool, start: f64, n: usize, of: f64) -> Vec { + let mirrored_run = (self.spread == GradientSpread::Reflect).then(|| mirror(one)); + let mut stops = Vec::with_capacity(one.len() * n); + for j in 0..n { + let run = match &mirrored_run { + Some(m) if (j % 2 == 1) != first_odd => m.as_slice(), + _ => one, + }; + stops.extend(run.iter().map(|s| GradientStop { offset: ((start + j as f64 + f64::from(s.offset)) / of) as f32, ..s.clone() })); + } + stops + } +} + +/// The smallest level of radial or diamond gradient `geom` over `area`, less a run (for rounding): +/// 0 when the area holds the rings' center (a radial's focal point, a diamond's center), else the +/// smallest along its sides, worked out in the gradient's unit frame from the sides' ends rather +/// than from samples along them, so a long side does not hide a run near it. 0 for a placement +/// without a frame. +fn inner_level(geom: &GradientGeom, kind: GradientKind, area: Rect) -> f64 { + let center = if kind == GradientKind::Radial { geom.focal_point() } else { geom.start }; + if area.contains(center) { + return 0.0; + } + let frame = if kind == GradientKind::Diamond { geom.diamond_frame() } else { geom.unit_frame() }; + let Some(to_unit) = frame.map(|m| m.inverse()) else { return 0.0 }; + let focal = geom.focal.filter(|_| kind == GradientKind::Radial).map(|f| (to_unit * f).to_vec2()); + let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)] + .map(|p| (to_unit * p).to_vec2()); + let mut least = f64::INFINITY; + for (&a, &b) in corners.iter().zip(corners.iter().cycle().skip(1)) { + let side = if kind == GradientKind::Diamond { diamond_least(a, b) } else { radial_least(a, b, focal) }; + least = least.min(side); + } + if least.is_finite() { (least - 1.0).max(0.0) } else { 0.0 } +} + +/// The smallest diamond level, |x| + |y| in its unit frame, on the segment from `a` to `b`: the +/// level is linear between the points where the segment crosses an axis, so the smallest is at +/// an end or at one of those crossings. +fn diamond_least(a: Vec2, b: Vec2) -> f64 { + let d = b - a; + let crossings = [(d.x != 0.0).then(|| -a.x / d.x), (d.y != 0.0).then(|| -a.y / d.y)]; + [Some(0.0), Some(1.0), crossings[0], crossings[1]] + .into_iter() + .flatten() + .filter(|u| (0.0..=1.0).contains(u)) + .map(|u| { + let p = a + d * u; + p.x.abs() + p.y.abs() + }) + .fold(f64::INFINITY, f64::min) +} + +/// Halvings at most in [`radial_least`]: enough to bring any finite span below a quarter level. +const RADIAL_HALVINGS: usize = 1100; + +/// The smallest radial level on the segment from `a` to `b` in the unit frame, at most a quarter +/// level under it: the distance to the center for a centered radial. An off-center radial's level +/// `t` is the circle of radius `t` about `f`(1 − `t`), `f` its focal point; the segment's distance +/// from that center less `t` falls as `t` grows (the center moves |`f`| < 1 per level), and the +/// smallest level is where it reaches 0, found by halving. 0 for a focal point not inside the +/// extent ellipse. +fn radial_least(a: Vec2, b: Vec2, focal: Option) -> f64 { + let Some(f) = focal else { return distance_to_segment(a, b, Vec2::ZERO) }; + let reach = f.hypot(); + if reach.is_nan() || reach >= 1.0 { + return 0.0; + } + let past = |t: f64| distance_to_segment(a, b, f * (1.0 - t)) <= t; + // At this level the circle reaches the segment's nearest point to the ellipse's center. + let mut hi = (distance_to_segment(a, b, Vec2::ZERO) / (1.0 - reach)).max(1.0); + let mut lo = 0.0; + if !hi.is_finite() || past(lo) { + return 0.0; + } + for _ in 0..RADIAL_HALVINGS { + let mid = lo + (hi - lo) / 2.0; + if hi - lo <= 0.25 || mid <= lo || mid >= hi { + break; + } + if past(mid) { hi = mid } else { lo = mid } + } + lo +} + +/// How far `c` is from the segment from `a` to `b`. +fn distance_to_segment(a: Vec2, b: Vec2, c: Vec2) -> f64 { + let d = b - a; + let along = (c - a).dot(d) / d.hypot2(); + let u = if along.is_finite() { along.clamp(0.0, 1.0) } else { 0.0 }; + (a + d * u - c).hypot() +} + +/// The stops of one run, from offset 0 to 1: the end colors held out to the ends. +fn whole_run(stops: &[GradientStop]) -> Vec { + let mut out = stops.to_vec(); + if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { + out.insert(0, GradientStop { offset: 0.0, ..first }); + } + if let Some(last) = out.last_mut() { + // The last stop's midpoint leads nowhere; the copy at 1 takes over. + if last.offset < 1.0 { + let end = GradientStop { offset: 1.0, ..last.clone() }; + out.push(end); + } + } + out +} + +/// 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()); + g.reverse(); + g.stops +} + +#[cfg(test)] +mod tests; diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs new file mode 100644 index 0000000000..2465039ca5 --- /dev/null +++ b/crates/color/src/gradient/spread/tests.rs @@ -0,0 +1,368 @@ +use kurbo::Vec2; + +use super::*; +use crate::gradient::{GradientPaint, MAX_REPEATS}; +use crate::{Color, GradientMethod}; + +fn spread(kind: GradientKind, spread: GradientSpread, repeats: u32) -> Gradient { + Gradient { spread, repeats, ..Gradient::new(kind, Gradient::default().stops) } +} + +fn black_white() -> Vec { + let mut a = GradientStop::new(0.0, Color::BLACK); + a.midpoint = 0.3; + vec![a, GradientStop::new(1.0, Color::WHITE)] +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +/// Repeat wraps the parameter, `repeats` times along the vector and on past both ends; Reflect +/// runs every other run backwards; Pad leaves it to the stops to clamp; a freeform gradient has +/// no ends to spread past. +#[test] +fn spread_maps_the_parameter_to_a_place_along_the_stops() { + let repeat = spread(GradientKind::Linear, GradientSpread::Repeat, 3); + for (t, want) in [(0.0, 0.0), (0.1, 0.3), (0.5, 0.5), (1.0 / 3.0 + 0.1, 0.3), (1.1, 0.3), (-0.1, 0.7)] { + assert!(close(repeat.spread_param(t), want), "repeat {t}: {}", repeat.spread_param(t)); + } + let reflect = spread(GradientKind::Radial, GradientSpread::Reflect, 3); + for (t, want) in [(0.0, 0.0), (0.1, 0.3), (1.0 / 3.0 + 0.1, 0.7), (2.0 / 3.0 + 0.1, 0.3), (1.0, 1.0), (-0.1, 0.3)] { + assert!(close(reflect.spread_param(t), want), "reflect {t}: {}", reflect.spread_param(t)); + } + let pad = spread(GradientKind::Linear, GradientSpread::Pad, 3); + assert_eq!((pad.spread_param(1.7), pad.spread_param(-0.2)), (1.7, -0.2)); + let free = spread(GradientKind::Freeform, GradientSpread::Repeat, 3); + assert_eq!(free.spread_param(0.7), 0.7); + assert!(!free.is_spread() && !pad.is_spread() && repeat.is_spread()); + assert!(repeat.spread_param(f64::NAN).is_nan()); + // A count out of range (set in code) counts as the nearest one in range. + assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 0).repeat_count(), 1); + assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 5000).repeat_count(), MAX_REPEATS); +} + +/// The native format writes the spread and count only when they are not Pad and 1, reads a file +/// without them as Pad and 1, and reads an unknown spread or a count out of range as the nearest +/// one it can hold. +#[test] +fn the_file_keeps_the_spread_and_reads_bad_values_leniently() { + let plain = serde_json::to_value(Gradient::default()).unwrap(); + assert!(plain.get("spread").is_none() && plain.get("repeats").is_none(), "{plain}"); + let g = spread(GradientKind::Conic, GradientSpread::Reflect, 4); + let v = serde_json::to_value(&g).unwrap(); + assert_eq!((v["spread"].as_str(), v["repeats"].as_u64()), (Some("Reflect"), Some(4))); + assert_eq!(serde_json::from_value::(v).unwrap(), g); + let read = |spread: serde_json::Value, repeats: serde_json::Value| { + let mut v = serde_json::to_value(Gradient::default()).unwrap(); + v["spread"] = spread; + v["repeats"] = repeats; + let g: Gradient = serde_json::from_value(v).unwrap(); + (g.spread, g.repeats) + }; + assert_eq!(read("Repeat".into(), 3.4.into()), (GradientSpread::Repeat, 3)); + assert_eq!(read("Spiral".into(), (-5).into()), (GradientSpread::Pad, 1)); + assert_eq!(read(7.into(), 1e12.into()), (GradientSpread::Pad, MAX_REPEATS)); + assert_eq!(read(serde_json::Value::Null, "many".into()), (GradientSpread::Pad, 1)); + let missing: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!((missing.spread, missing.repeats), (GradientSpread::Pad, 1)); +} + +/// One run's placement: a level of the period is the gradient's level times the count, for +/// linear, diamond and off-center radial gradients; a conic keeps its placement. +#[test] +fn a_period_holds_one_run_of_the_stops() { + let p = Point::new(72.0, 61.0); + let linear = GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(60.0, 40.0), aspect: 1.0, focal: None }; + let radial = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(60.0, 50.0), aspect: 0.6, focal: Some(Point::new(55.0, 52.0)) }; + let diamond = GradientGeom { end: Point::new(58.0, 56.0), focal: None, ..radial }; + for (kind, geom) in [(GradientKind::Linear, linear), (GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] { + let period = geom.period(kind, 4); + assert!(close(period.param_at(kind, p), 4.0 * geom.param_at(kind, p)), "{kind:?}"); + assert_eq!(period.focal, geom.focal, "{kind:?}"); + } + assert_eq!(radial.period(GradientKind::Conic, 4), radial); + assert_eq!(linear.period(GradientKind::Linear, 0), linear, "a count of 0 counts as 1"); +} + +/// The stops repeated over the area: as many runs as its corners reach, each in place. +#[test] +fn a_linear_gradient_pads_with_its_stops_repeated_over_the_area() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(-5.0, 0.0, 25.0, 10.0)).unwrap(); + assert!(!p.capped); + // Runs of 10 from -10 to 30: four of them. + assert_eq!((p.geom.start, p.geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); + let offsets: Vec = p.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); + assert!(p.gradient.stops.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); + assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1)); + // Every point gets the color the gradient gives it. + for x in [-4.0, 1.0, 7.5, 13.0, 24.0] { + let q = Point::new(x, 5.0); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0; + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0; + assert!(want.to_rgb().iter().zip(got.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-4), "{x}: {want:?} vs {got:?}"); + } +} + +#[test] +fn reflect_mirrors_every_other_run_and_its_midpoints() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap(); + let colors: Vec = p.gradient.stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); + // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. + assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops); +} + +#[test] +fn stops_inside_a_run_hold_their_colors_to_its_ends() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let stops = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; + let g = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, stops) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap(); + let offsets: Vec = p.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); +} + +/// An off-center radial grows about its focal point, a diamond about its center; every point +/// keeps its color. +#[test] +fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { + let c = Point::new(50.0, 50.0); + let radial = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; + let diamond = GradientGeom { focal: None, ..radial }; + for (kind, geom) in [(GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 100.0)).unwrap(); + assert!(p.gradient.stops.len() / 2 >= 10, "{kind:?}: {}", p.gradient.stops.len()); + assert_eq!(p.geom.focal, geom.focal); + for q in [Point::new(72.0, 61.0), Point::new(10.0, 95.0), Point::new(52.0, 49.0)] { + let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } +} + +/// 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. +#[test] +fn far_repeats_and_many_stops_are_capped() { + 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(); + assert!(p.capped); + assert_eq!(p.gradient.stops.len(), 2 * MAX_SPREAD_RUNS); + let many: Vec = (0..=4000).map(|i| GradientStop::new(i as f32 / 4000.0, Color::gray(i as f32 / 4000.0))).collect(); + 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!(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()); + let empty = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, vec![]) }; + assert!(empty.padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); +} + +/// The runs written are the ones the area shows: a radial or diamond centered a thousand runs +/// from the area writes the hundred it crosses, not the first 256 from the center; a linear +/// gradient over an area of 1,100 runs far before its start, capped, keeps the runs nearest it. +/// Every point checked gets the color the gradient gives it. +#[test] +fn the_runs_written_cover_a_distant_area() { + let centered = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(1.0, 50.0), aspect: 1.0, focal: None }; + let before = GradientGeom { start: Point::new(5000.0, 0.0), end: Point::new(5001.0, 0.0), aspect: 1.0, focal: None }; + for (kind, geom, area) in [ + (GradientKind::Radial, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)), + (GradientKind::Diamond, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)), + (GradientKind::Linear, before, Rect::new(0.0, 0.0, 1100.0, 100.0)), + ] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, area).unwrap(); + assert_eq!(p.capped, kind == GradientKind::Linear, "{kind:?}"); + assert!(p.gradient.stops.len() <= MAX_SPREAD_STOPS); + for q in [Point::new(1000.5, 0.5), Point::new(1050.25, 50.0), Point::new(1099.5, 99.5), Point::new(1010.75, 90.0)] { + let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } +} + +/// A tiny radial centered just outside the area, beside a side between any evenly spaced +/// samples of it: the runs written start from the area's true innermost level. +#[test] +fn the_innermost_level_is_found_between_samples() { + let c = Point::new(-0.0005, 7.8125); + let geom = GradientGeom { start: c, end: c + Vec2::new(0.001, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 1000.0, 1000.0)).unwrap(); + for q in [Point::new(0.05, 7.8125), Point::new(0.1, 7.9)] { + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Radial, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Radial, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{q:?}: {want:?} vs {got:?}"); + } +} + +/// An area four billion units wide whose long side passes 50 runs from a tiny radial or diamond's +/// center: the runs written start from that side's true innermost level, so the runs beside it +/// keep their colors. +#[test] +fn the_innermost_level_is_found_along_a_very_long_side() { + let area = Rect::new(-1.3e9, 0.0, 2.9e9, 1.0); + for x in [0.3, 12345.678, -7777.25, 98765.4321] { + let c = Point::new(x, -0.00005); + let run = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None }; + let off_center = GradientGeom { focal: Some(c + Vec2::new(0.5e-6, 0.0)), ..run }; + for (kind, geom) in [(GradientKind::Diamond, run), (GradientKind::Radial, run), (GradientKind::Radial, off_center)] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, area).unwrap(); + assert!(p.capped, "{kind:?}"); + for q in [Point::new(x, 1e-5), Point::new(x + 2e-5, 2e-5), Point::new(x - 1e-5, 0.0001)] { + let level = geom.param_at(kind, q); + assert!((40.0..200.0).contains(&level), "{kind:?} {q:?}: level {level}"); + let want = g.sample(g.spread_param(level) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } + } +} + +/// Runs a billion levels from a radial's center are past what the stops' offsets can tell apart: +/// the gradient is written capped, its stops still apart and in order. +#[test] +fn runs_too_far_from_the_center_are_capped() { + let c = Point::new(0.0, 0.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) }; + let p = g.padded_over(&geom, Rect::new(1000.0, 0.0, 1000.0001, 0.0001)).unwrap(); + assert!(p.capped); + assert!(p.gradient.stops.windows(2).all(|w| w[0].offset <= w[1].offset), "in order"); + let distinct = p.gradient.stops.windows(2).filter(|w| w[1].offset > w[0].offset).count(); + assert!(distinct * 2 + 2 >= p.gradient.stops.len(), "the runs stay apart: {distinct} of {}", p.gradient.stops.len()); +} + +/// A run of 1,000 close stops is written only as far out as their offsets stay apart: around +/// level 50,000 it is capped, every stop written apart from the next. +#[test] +fn close_stops_are_written_only_where_they_stay_apart() { + let many: Vec = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray(i as f32 / 1000.0))).collect(); + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.001, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: 1, ..Gradient::new(GradientKind::Diamond, many) }; + let p = g.padded_over(&geom, Rect::new(50.0, 0.0, 50.002, 0.002)).unwrap(); + assert!(p.capped); + // Only where one run meets the next do two stops share an offset. + let runs = p.gradient.stops.len() / 1001; + let shared = p.gradient.stops.windows(2).filter(|w| w[1].offset <= w[0].offset).count(); + assert!(shared < runs, "{shared} stops share an offset in {runs} runs"); + // Stops 0.0001234 of a run apart are written out to level 123, on whole runs: an area within + // it gets the gradient's colors, Reflect's mirrored runs included. + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.0001234, Color::gray(0.5)), GradientStop::new(1.0, Color::WHITE)]; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Diamond, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_over(&geom, Rect::new(121.0, 0.0, 123.9, 0.2)).unwrap(); + assert!(p.capped, "past level 123 it pads"); + for x in [121.3, 121.9, 122.5] { + let q = Point::new(x, 0.1); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Diamond, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Diamond, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } +} + +/// A piece of a gradient that starts where two runs meet (a gradient along a stroke, sliced at a +/// corner there) starts with the new run's first color, not the old run's last. +#[test] +fn a_piece_starting_at_a_sharp_step_takes_the_color_after_it() { + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, Gradient::default().stops) }; + let piece = g.unrolled().span(0.5, 1.0); + assert_eq!(piece.stops.first().map(|s| s.color), Some(Color::WHITE), "{:?}", piece.stops); + assert_eq!(piece.stops.last().map(|s| s.color), Some(Color::BLACK)); + let before = g.unrolled().span(0.0, 0.5); + assert_eq!((before.stops[0].color, before.stops.last().unwrap().color), (Color::WHITE, Color::BLACK)); +} + +/// An area very far past a very short reflecting vector (a file's numbers) is padded without the +/// run count overflowing. +#[test] +fn a_far_area_pads_without_overflowing() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1e-300, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(1e3, 0.0, 2e3, 1.0)); + assert!(p.is_none_or(|p| p.gradient.stops.len() <= MAX_SPREAD_STOPS)); + // Runs of 1e-8: the area's levels lie past 1e19, more than an integer holds. + let geom = GradientGeom { end: Point::new(1e-6, 0.0), ..geom }; + let p = g.padded_over(&geom, Rect::new(1e11, 0.0, 1e11 + 1.0, 1.0)).unwrap(); + assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {} {:?}", p.capped, p.gradient.stops.len(), p.geom); +} + +/// Unrolled, the stops run `repeats` times between 0 and 1 (every other run mirrored with +/// Reflect); one cycle is a run, or a run and its mirror. +#[test] +fn unrolled_and_cycle_stops() { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Linear, black_white()) }; + let u = g.unrolled(); + let offsets: Vec = u.stops.iter().map(|s| (s.offset * 3.0).round()).collect(); + assert_eq!(offsets, [0.0, 1.0, 1.0, 2.0, 2.0, 3.0]); + let colors: Vec = u.stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK, Color::BLACK, Color::WHITE]); + for t in [0.05, 0.4, 0.71, 0.99] { + let (a, b) = (g.sample(g.spread_param(t) as f32).0.to_rgb(), u.sample(t as f32).0.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{t}"); + } + let (cycle, runs) = g.cycle().unwrap(); + assert_eq!((runs, cycle.stops.len()), (2, 4)); + let repeat = Gradient { spread: GradientSpread::Repeat, ..g.clone() }; + assert_eq!(repeat.cycle().unwrap().1, 1); + assert!(Gradient::default().cycle().is_none()); + assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); +} + +/// 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] +fn pixels_repeat_and_reflect_for_every_kind() { + let b = Rect::new(0.0, 0.0, 40.0, 40.0); + let c = Point::new(20.0, 20.0); + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + for sp in [GradientSpread::Repeat, GradientSpread::Reflect] { + for method in [GradientMethod::Classic, GradientMethod::Perceptual] { + let mut g = GradientPaint::new(Gradient { spread: sp, repeats: 3, method, ..Gradient::new(kind, Gradient::default().stops) }); + let geom = if kind == GradientKind::Linear { + GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(40.0, 10.0), aspect: 1.0, focal: None } + } else { + GradientGeom { start: c, end: c + Vec2::new(18.0, 4.0), aspect: 0.8, focal: None } + }; + g.geom = Some(geom); + let px: Vec<[u8; 4]> = g.pixels(b, b, 40, 40, false, &Color::to_rgb).collect(); + for (i, got) in px.iter().enumerate().step_by(37) { + let p = Point::new((i % 40) as f64 + 0.5, (i / 40) as f64 + 0.5); + let t = g.gradient.spread_param(geom.param_at(kind, p)); + // Not where Repeat jumps from the last stop back to the first. + if sp == GradientSpread::Repeat && !(0.03..0.97).contains(&t) { + continue; + } + let want = g.gradient.sample(t as f32).0.to_rgba8(1.0); + assert!(got[0].abs_diff(want[0]) <= 2, "{kind:?} {sp:?} {method:?} {p:?}: {got:?} vs {want:?}"); + } + } + } + } +} + +/// A conic gradient's wedges: as many for each time the stops run around as for one turn, at most +/// so many. +#[test] +fn conic_wedges_count_each_run() { + let g = spread(GradientKind::Conic, GradientSpread::Repeat, 4); + assert_eq!(g.conic_wedge_count(360), 1440); + assert_eq!(spread(GradientKind::Conic, GradientSpread::Pad, 4).conic_wedge_count(360), 360); + assert_eq!(spread(GradientKind::Conic, GradientSpread::Reflect, MAX_REPEATS).conic_wedge_count(360), crate::gradient::MAX_CONIC_WEDGES); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 03e7dc10e7..0d84e9e6b3 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -16,7 +16,7 @@ pub mod swatch; pub use blend::BlendMode; pub use freeform::{Freeform, FreeformMode, FreeformPoint}; -pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop}; +pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop}; pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 800785f6f7..996c963c11 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -196,8 +196,10 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { let at = if mirrored { 1.0 - s.offset } else { s.offset }; format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0) }; + // Stops that repeat or reflect run that many times across the box. + let unrolled = g.gradient.unrolled(); let stops: Vec = - if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.stops.iter().map(stop).collect() }; + if mirrored { unrolled.stops.iter().rev().map(stop).collect() } else { unrolled.stops.iter().map(stop).collect() }; match g.gradient.kind { GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), // CSS sweeps clockwise from straight up; the angle is counted counterclockwise diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index eaee50dd59..f3de69baf9 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -261,6 +261,29 @@ fn css_preserves_fractional_gradient_stop_positions() { } } +/// A gradient swatch that repeats or reflects has its stops written that many times across the +/// box, every other run backwards for Reflect. +#[test] +fn css_writes_spread_gradient_swatches_with_their_stops_repeated() { + for (spread, want) in [ + (crate::GradientSpread::Repeat, "#ffffff 0%, #000000 50%, #ffffff 50%, #000000 100%"), + (crate::GradientSpread::Reflect, "#ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%"), + ] { + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { + name: "Bands".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { spread, repeats: 2, ..Gradient::default() }))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains(&format!("--bands: linear-gradient(90deg, {want});")), "{spread:?}: {text}"); + } +} + #[test] fn holds_counts_the_swatches_each_format_writes() { // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 4e51777060..2f8b40014e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -152,6 +152,11 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option (g.gradient.spread, g.gradient.repeat_count()), + p => panic!("a gradient, not {p:?}"), + }; + let (two, twenty) = (spread(GradientSpread::Repeat, 2), spread(GradientSpread::Repeat, 20)); + assert_eq!(at(&two, &twenty, 0.25), (GradientSpread::Repeat, 7)); + assert_eq!(at(&two, &twenty, 0.5), (GradientSpread::Repeat, 11)); + assert_eq!(at(&two, &twenty, 1.0), (GradientSpread::Repeat, 20)); + let pad = spread(GradientSpread::Pad, 1); + assert_eq!(at(&pad, &twenty, 0.25), (GradientSpread::Pad, 1)); + assert_eq!(at(&pad, &twenty, 0.75), (GradientSpread::Repeat, 20)); + } + #[test] fn blend_between_tints_of_one_swatch_keeps_the_link() { let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint }; diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 4bddf80b0a..ec54be20cd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -367,9 +367,12 @@ pub struct Slice { } impl Slice { - /// The slice's paint: its piece of `g` as a linear gradient placed from `from` to `to`. - pub fn paint(&self, g: &Gradient) -> Paint { - let gradient = g.span(self.span.0 as f32, self.span.1 as f32); + /// The slice's paint: its piece of `unrolled` as a linear gradient placed from `from` to `to`. + /// `unrolled` is the stroke's gradient as [`Gradient::unrolled`] gives it (unrolled once for + /// all the slices), so one that repeats or reflects runs its stops that many times along (or + /// across) the stroke. + pub fn paint(&self, unrolled: &Gradient) -> Paint { + let gradient = unrolled.span(self.span.0 as f32, self.span.1 as f32); let end = if self.from.distance(self.to) > 1e-9 { self.to } else { self.from + Vec2::new(1.0, 0.0) }; let geom = GradientGeom { start: self.from, end, aspect: 1.0, focal: None }; Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle: geom.angle_deg(), ..GradientPaint::new(gradient) })) @@ -471,35 +474,60 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: _ => vectorcraft_pathops::stroke_region(outlines).to_bezpath(), }; let grow = if g.stops.iter().all(|s| s.opacity >= 1.0) { SLICE_GROW } else { 0.0 }; + let unrolled = g.unrolled(); let slices = gradient_slices(bp, rule, st, SLICE_TOL, SLICE_MARGIN, grow) .into_iter() .map(|s| { - let paint = s.paint(g); + let paint = s.paint(&unrolled); (s.shape, paint) }) .collect(); Some(WrittenSlices { clip, slices }) } +/// How far before and after a sharp step between two colors (two stops at one offset, as a +/// gradient that repeats has where one run meets the next) [`mesh_marks`] puts a mesh row or +/// column, as a fraction of the gradient's length. +const STEP_GAP: f64 = 1e-4; + +/// Where along gradient `g` (0 to 1) Outline Stroke's mesh puts a row or column for its stops +/// ([`Gradient::expanded_stops`]): at each stop, and for two stops at one offset (a sharp step, +/// or where a reflecting gradient turns back) also [`STEP_GAP`] before and after it, so the mesh +/// takes the color on each side. +fn mesh_marks(g: &Gradient) -> Vec { + let offsets: Vec = g.expanded_stops().iter().map(|s| f64::from(s.0)).collect(); + let shared = |i: usize| { + let t = offsets.get(i).copied().unwrap_or(0.0); + let near = |j: Option| j.and_then(|j| offsets.get(j)).is_some_and(|&u| (u - t).abs() < 1e-6); + near(i.checked_sub(1)) || near(i.checked_add(1)) + }; + let mut out = Vec::with_capacity(offsets.len() + 2); + for (i, &t) in offsets.iter().enumerate() { + out.push(t); + if shared(i) { + out.extend([(t - STEP_GAP).max(0.0), (t + STEP_GAP).min(1.0)]); + } + } + out +} + /// 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. pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { + let g = &*g.unrolled(); 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 { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; + let marks: Vec = if across { vec![] } else { at_stops.clone() }; 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(g.expanded_stops().iter().map(|s| s.0 as f64)).collect() - } else { - vec![0.5] - }; + 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] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index ea966ea076..3745e4a6f6 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -286,6 +286,65 @@ fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { assert_covered(&hairpin, &st); } +/// A gradient along or across a stroke that repeats or reflects runs its stops that many times +/// along (or across) it: a slice's paint, and Outline Stroke's meshes, give each point the color +/// the spread gives its place. +#[test] +fn a_spread_gradient_runs_its_stops_along_the_stroke_that_many_times() { + use vectorcraft_color::GradientSpread; + let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false); + for (mode, spread) in [StrokeGradientMode::Along, StrokeGradientMode::Across] + .into_iter() + .flat_map(|m| [GradientSpread::Reflect, GradientSpread::Repeat].map(|s| (m, s))) + { + let mut st = stroke(10.0, mode); + let g = Gradient { spread, repeats: 3, ..Gradient::default() }; + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + for s in slices(&bp, &st) { + let Paint::Gradient(p) = s.paint(&g.unrolled()) else { panic!("a gradient slice") }; + let geom = p.geom.unwrap(); + for u in [0.1, 0.5, 0.9] { + let at = s.from + (s.to - s.from) * u; + let t = s.span.0 + (s.span.1 - s.span.0) * u; + // Where two runs meet, Repeat's color is the next run's start or the last one's end. + if (t * 3.0 - (t * 3.0).round()).abs() < 1e-9 { + continue; + } + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = p.gradient.sample(geom.param_at(vectorcraft_color::GradientKind::Linear, at) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-3), "{mode:?} at {t}: {want:?} vs {got:?}"); + } + } + // Outline Stroke: the mesh comes back to white where each run starts again, and reaches + // black where each run ends, on both sides of a sharp step between runs. + let count = |g: &Gradient, hex: &str| { + gradient_meshes(&bp, FillRule::NonZero, &st, g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == hex).count() + }; + for hex in ["#ffffff", "#000000"] { + let (spread, pad) = (count(&g, hex), count(&Gradient::default(), hex)); + assert!(spread > pad, "{mode:?} {:?} {hex}: {spread} vs {pad}", g.spread); + } + } +} + +/// Where a reflecting gradient turns back past a narrow last stop, Outline Stroke's mesh keeps that +/// stop's color. +#[test] +fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { + use vectorcraft_color::GradientSpread; + let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false); + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.995, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let mut st = stroke(10.0, mode); + let g = Gradient { spread: GradientSpread::Reflect, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops.clone()) }; + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let whites = + gradient_meshes(&bp, FillRule::NonZero, &st, &g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == "#ffffff").count(); + // The gradient turns back at white 50 times. + assert!(whites >= 50, "{mode:?}: {whites}"); + } +} + /// #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 1d39e2b4e4..5737235963 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)} 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 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 ), @@ -476,11 +476,14 @@ 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); + let g = &*g; let frame = Frame::new(g, bounds); match to { GradientTo::Mesh => vec![Node::new(d.alloc_id(), NodeKind::Mesh(frame.mesh(&g.gradient)))], GradientTo::Objects(n) => frame - .pieces(&g.gradient, n as usize) + .pieces(&g.gradient, n as usize, runs) .into_iter() .map(|(shape, (color, opacity))| { let fill = FillLayer { opacity, overprint: f.overprint, ..FillLayer::new(Paint::solid(color)) }; @@ -1234,8 +1237,64 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model (super::paint::stored_color(store_in, c), a) }); } + let (geom, spread) = (g.resolve(bounds), g.gradient.spreader()); + Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32)) +} + +/// `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)>) { + use std::borrow::Cow; + if !g.gradient.is_spread() { + return (Cow::Borrowed(g), None); + } let geom = g.resolve(bounds); - Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32)) + match g.gradient.padded_over(&geom, bounds) { + 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) + } + 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) + } + } +} + +/// The edges (gradient parameters, from `lo` to `hi`) of about `n` bands [`Frame::pieces`] cuts: +/// evenly spaced, or for stops repeated in `runs` ([`padded_on`]: where the first run starts and +/// how many runs lie evenly from there to 1) the same whole number of bands in each run between +/// `lo` and `hi` (at least one, about `n` in all), so every run starts on an edge and no band +/// holds two runs' colors. +fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec { + let n = n.max(1); + let even = || { + let step = (hi - lo) / n as f64; + (0..=n).map(|k| lo + k as f64 * step).collect() + }; + let Some((first, count)) = runs.filter(|&(first, count)| count > 0 && (0.0..1.0).contains(&first)) else { return even() }; + let run = (1.0 - first) / count as f64; + let (from, to) = (lo.max(first), hi.min(1.0)); + let shown = (to - from) / run; + if !(shown > 0.0 && shown.is_finite()) { + return even(); + } + let step = run / (n as f64 / shown).round().max(1.0); + // Both whole numbers of steps from `first`, at most `n` plus the runs shown, plus one. + let (k0, k1) = (((from - first) / step).ceil(), ((to - first) / step).floor()); + let mut edges = vec![lo]; + for k in (k0 as usize)..(k1 as usize).saturating_add(1) { + let t = first + k as f64 * step; + if t > edges.last().copied().unwrap_or(lo) + 1e-12 && t < hi - 1e-12 { + edges.push(t); + } + } + edges.push(hi); + edges } /// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every @@ -1404,10 +1463,17 @@ impl Frame { /// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the /// center from each band's start to the seam, for a diamond one, nested squares (largest /// first). Each paints over the ones below except its band, so no seam shows between bands. - /// With translucent stops each shape is just its band, so nothing doubles up. - fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { + /// With translucent stops each shape is just its band, so nothing doubles up. Stops repeated + /// in `runs` ([`padded_on`]) get the same whole number of bands in each run ([`band_edges`]). + fn pieces(&self, g: &Gradient, n: usize, runs: Option<(f64, usize)>) -> Vec<(PathData, (Color, f32))> { let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); + let color = |e: &[f64], k: usize| { + let (a, b) = (e.get(k).copied().unwrap_or(0.0), e.get(k + 1).copied().unwrap_or(1.0)); + g.sample_keep_model(((a + b) / 2.0) as f32) + }; if let Some((geom, b, _)) = &self.conic { + // Evenly spaced around the turn, as the wedges are. + let n = band_edges(0.0, 1.0, n, runs).len().saturating_sub(1); return geom .conic_wedges(*b, n, opaque) .into_iter() @@ -1419,51 +1485,52 @@ impl Frame { return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))]; }; // Nested squares about the center, largest first, as a radial gradient's ellipses. - let step = reach / n as f64; + let edges = band_edges(0.0, *reach, n, runs); let square = |t: f64| -> SubPath { let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); SubPath::polyline(&pts, true) }; - return (0..n) + return (0..edges.len().saturating_sub(1)) .rev() .map(|k| { - let mut shape = PathData::single(square((k + 1) as f64 * step)); + let mut shape = PathData::single(square(edges.get(k + 1).copied().unwrap_or(*reach))); if !opaque && k > 0 { - let mut hole = square(k as f64 * step); + let mut hole = square(edges.get(k).copied().unwrap_or(0.0)); hole.reverse(); shape.subpaths.push(hole); } - (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + (shape, color(&edges, k)) }) .collect(); } - let step = (self.t1 - self.t0) / n as f64; - let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); + let edges = band_edges(self.t0, self.t1, n, runs); + let bands = edges.len().saturating_sub(1); + let edge = |k: usize| edges.get(k).copied().unwrap_or(self.t1); if self.radial { let ellipse = |t: f64| { let c = self.focal + (self.start - self.focal) * t; let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]); shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m) }; - return (0..n) + return (0..bands) .rev() .map(|k| { - let mut shape = ellipse((k + 1) as f64 * step); + let mut shape = ellipse(edge(k + 1)); if !opaque && k > 0 { - let mut hole = ellipse(k as f64 * step).subpaths.remove(0); + let mut hole = ellipse(edge(k)).subpaths.remove(0); hole.reverse(); shape.subpaths.push(hole); } - (shape, color(k)) + (shape, color(&edges, k)) }) .collect(); } - (0..n) + (0..bands) .map(|k| { - let a = self.t0 + k as f64 * step; - let b = if opaque { self.t1 } else { a + step }; + let a = edge(k); + let b = if opaque { self.t1 } else { edge(k + 1) }; let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)]; - (PathData::single(SubPath::polyline(&pts, true)), color(k)) + (PathData::single(SubPath::polyline(&pts, true)), color(&edges, k)) }) .collect() } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9003e325a9..090a7cb46c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -1,7 +1,8 @@ //! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::gradient::MAX_REPEATS; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_geom::{Affine, Point, Rect}; @@ -23,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -129,6 +130,36 @@ fn method_param(p: &Value) -> Parsed> { } } +/// The `spread` param of `p`, if given: what the gradient paints past the ends of its stops. +fn spread_param(p: &Value) -> Parsed> { + match p.get("spread") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(GradientSpread::parse).map(Some).ok_or_else(|| "`spread` must be pad, repeat or reflect".into()), + } +} + +/// The `repeats` param of `p`, if given: how many times the stops run along the vector with +/// Repeat and Reflect, a whole number from 1 to [`MAX_REPEATS`]. +fn repeats_param(p: &Value) -> Parsed> { + match p.get("repeats") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=f64::from(MAX_REPEATS)).contains(n)) + .map(|n| Some(n as u32)) + .ok_or_else(|| format!("`repeats` must be a whole number from 1 to {MAX_REPEATS}")), + } +} + +/// An error when `p` gives a spread other than pad for a gradient of `kind` that has no ends to +/// spread past (freeform). +fn spread_for(kind: GradientKind, p: &Value) -> Parsed<()> { + match spread_param(p)? { + Some(s) if !s.is_pad() && kind == GradientKind::Freeform => Err("`spread` applies to linear, radial, conic and diamond gradients".into()), + _ => Ok(()), + } +} + /// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` /// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and /// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps @@ -271,6 +302,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { Some(v) => v.as_bool().ok_or("`dither` must be true or false")?, }; gp.gradient.method = method_param(g)?.unwrap_or_default(); + spread_for(kind, g)?; + gp.gradient.spread = spread_param(g)?.unwrap_or_default(); + gp.gradient.repeats = repeats_param(g)?.unwrap_or(1); gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; @@ -422,6 +456,13 @@ fn apply_gradient_edit_in_model( if let Some(m) = method_param(p)? { gp.gradient.method = m; } + spread_for(gp.gradient.kind, p)?; + if let Some(sp) = spread_param(p)? { + gp.gradient.spread = sp; + } + if let Some(n) = repeats_param(p)? { + gp.gradient.repeats = n; + } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; if let Some(a) = p.get("angle").and_then(Value::as_f64) { @@ -516,6 +557,18 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } _ => std::borrow::Cow::Borrowed(p), }; + // A spread for selected gradients leaves a freeform default (which has no ends) as it is. + let spreads = matches!(spread_param(p), Ok(Some(sp)) if !sp.is_pad()); + let for_default = match for_default { + q if spreads && !ids.is_empty() && default_kind == Some(GradientKind::Freeform) => { + let mut q = q.into_owned(); + if let Some(o) = q.as_object_mut() { + o.remove("spread"); + } + std::borrow::Cow::Owned(q) + } + q => q, + }; let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index d27809fa78..29de2210ae 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -4,7 +4,7 @@ //! The built-in presets are the gradient swatches of the built-in swatch libraries //! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its //! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]), -//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a +//! so every document has them. A preset is the color ramp (type, stops, method, dither, spread), not a //! placement: applying one edits the gradient in place, as `paint.editGradient` does. use serde::{Deserialize, Serialize}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient Presets", [], None, - "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", + "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?, spread?, repeats?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", always, list ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Apply Gradient Preset", [], None, - "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", + "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops, method, spread and repeats (a freeform preset's: the first three), and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", has_doc, apply ), @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Save Gradient Preset", [], None, - "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}", + "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method, dither, spread and repeats (stops linked to a swatch keep their colour, unlinked) → {name}", can_save, save ), @@ -90,12 +90,13 @@ fn builtin_presets() -> impl Iterator { }) } -/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside? +/// Do `a` and `b` have the same color ramp (type, method, dither, spread as it paints and stops), +/// swatch links aside? fn same_ramp(a: &Gradient, b: &Gradient) -> bool { let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| { (x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint) }; - (a.kind, a.method, a.dither) == (b.kind, b.method, b.dither) + (a.kind, a.method, a.dither, a.painted_spread()) == (b.kind, b.method, b.dither, b.painted_spread()) && a.stops.len() == b.stops.len() && a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y)) } @@ -215,6 +216,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { o.insert("kind".into(), json!(g.kind.label().to_lowercase())); o.insert("stops".into(), stops_json(&g.stops)); o.insert("method".into(), json!(g.method.label().to_lowercase())); + // A freeform preset takes no spread (paint.editGradient refuses one). + if g.kind != vectorcraft_color::GradientKind::Freeform { + o.insert("spread".into(), json!(g.spread.label().to_lowercase())); + o.insert("repeats".into(), json!(g.repeat_count())); + } // Dither is a fill's (paint.editGradient refuses it on a stroke). if !stroke { o.insert("dither".into(), json!(g.dither)); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b199f9a356..c3b24463ae 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index c809d58090..751313957f 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -393,11 +393,12 @@ fn float_key(v: f32) -> Option { /// keys. type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); -/// A gradient's kind, method, dither and stops, as keys. -type GradientKey<'a> = (u8, u8, bool, Vec>); +/// A gradient's kind, method, dither, spread and count as it paints them, and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, (vectorcraft_color::GradientSpread, u32), Vec>); -/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when -/// any of its numbers is NaN, so the gradient equals no gradient. +/// A key that is equal for two gradients exactly when the gradients are equal (`==`), apart from +/// a count that a gradient which pads does not use ([`Gradient::painted_spread`]); `None` when any +/// of its numbers is NaN, so the gradient equals no gradient. fn gradient_key(g: &Gradient) -> Option> { let kind = match g.kind { GradientKind::Linear => 0, @@ -410,7 +411,7 @@ fn gradient_key(g: &Gradient) -> Option> { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, }; - Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) + Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?)) } /// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index 73e0172da5..a2aea6341d 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -881,3 +881,134 @@ fn making_a_gradient_diamond_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(fill_gradient(&s, id), before); } + +/// `paint.setFill` and `paint.editGradient` take a spread and a count for each kind of gradient +/// that has ends; the params give them back, the native format keeps them, and a change is one +/// undo step while an edit that changes nothing records none. +#[test] +fn a_gradient_takes_a_spread_and_a_count() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + for kind in ["linear", "radial", "conic", "diamond"] { + s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "spread": "Reflect", "repeats": 4}})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Reflect, 4), "{kind}"); + let params = vectorcraft_tools::params::gradient_params(&g); + assert_eq!((params["spread"].as_str(), params["repeats"].as_u64()), (Some("reflect"), Some(4)), "{kind}"); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.spread, GradientSpread::Pad, "a new gradient pads"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient, g.gradient, "{kind}"); + } + let steps = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 2)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing changed: no step"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.repeats, 2, "Pad keeps the count for later"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_gradient(&s, id), g); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") }; + assert_eq!((saved.gradient.spread, saved.gradient.repeats), (GradientSpread::Repeat, 2)); + // The params of a gradient that pads give back the count it keeps. + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!((fill_gradient(&s, id).gradient.spread, fill_gradient(&s, id).gradient.repeats), (GradientSpread::Pad, 2), "{params}"); +} + +/// A spread or count a gradient can't take is an error, from every command that sets one, and the +/// gradient stays as it was: an unknown spread, a count that is not a whole number from 1 to 100, +/// and a spread other than pad on a freeform gradient. +#[test] +fn a_bad_spread_or_count_is_refused() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "spread": "repeat", "repeats": 3}})).unwrap(); + let before = fill_gradient(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + for bad in [json!({"spread": "mirror"}), json!({"spread": 1}), json!({"spread": true})] { + let e = s.execute("paint.editGradient", &bad).unwrap_err().to_string(); + assert!(e.contains("`spread` must be pad, repeat or reflect"), "{bad}: {e}"); + } + for n in [json!(0), json!(-2), json!(1.5), json!(101), json!(1e9), json!("3"), json!(f64::MAX)] { + let e = s.execute("paint.editGradient", &json!({ "repeats": n })).unwrap_err().to_string(); + assert!(e.contains("`repeats` must be a whole number from 1 to 100"), "{n}: {e}"); + let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "repeats": n}})).unwrap_err().to_string(); + assert!(e.contains("`repeats`"), "{n}: {e}"); + } + let freeform = json!({"kind": "freeform", "spread": "reflect"}); + let e = s.execute("paint.editGradient", &freeform).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to linear, radial, conic and diamond gradients"), "{e}"); + let e = s.execute("paint.setFill", &json!({ "gradient": freeform })).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to"), "{e}"); + assert_eq!(fill_gradient(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // Pad on a freeform gradient is allowed (it has nothing to spread), and 100 is the most. + s.execute("paint.editGradient", &json!({"repeats": 100})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform", "spread": "pad"})).unwrap(); + // A repeating gradient turned freeform: its params, which carry no spread, set it again. + s.execute("paint.editGradient", &json!({"kind": "radial", "spread": "reflect"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + assert!(params.get("spread").is_none(), "{params}"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); +} + +/// A spread for a selected gradient applies while the gradient used last, the default, is a +/// freeform one, which keeps no spread. +#[test] +fn a_spread_for_a_selected_gradient_leaves_a_freeform_default_alone() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform"}})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [a.0], "spread": "repeat", "repeats": 3})).unwrap(); + let g = fill_gradient(&s, a); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 3)); +} + +/// A preset keeps its spread and count: saved from a gradient, applied to another object. +#[test] +fn a_gradient_preset_keeps_its_spread() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "spread": "reflect", "repeats": 5}})).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Fan"})).unwrap(); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("gradient.presets.apply", &json!({"name": "Fan"})).unwrap(); + let g = fill_gradient(&s, b); + assert_eq!((g.gradient.kind, g.gradient.spread, g.gradient.repeats), (GradientKind::Conic, GradientSpread::Reflect, 5)); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); +} + +/// A gradient set back to Pad keeps its count for later, and still matches a saved preset and a +/// swatch of the same gradient; while it repeats, the spread tells them apart. +#[test] +fn a_padding_gradient_matches_whatever_count_it_keeps() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let ramp = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("paint.setFill", &json!({ "gradient": ramp })).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Plain"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Plain", "paint": {"gradient": ramp}})).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().contains(&json!("Plain")); + assert!(!unused(&mut s)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 5})).unwrap(); + assert_eq!(s.active_saved_gradient_preset(), None, "a repeating gradient is another ramp"); + assert!(unused(&mut s), "nothing paints the swatch's gradient"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); + assert_eq!(s.active_saved_gradient_preset(), Some("Plain")); + assert!(!unused(&mut s), "the gradient paints as the swatch does"); +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 84e5559851..3dacee533b 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -464,6 +464,50 @@ fn a_turned_or_flat_diamond_expands_as_the_canvas_draws_it() { } } +/// A gradient that repeats or reflects expands, into objects or a mesh, as the canvas paints it: +/// its stops repeated across the box, for each kind. +#[test] +fn a_spread_gradient_expands_as_the_canvas_paints_it() { + for kind in ["linear", "radial", "conic", "diamond"] { + for spread in ["repeat", "reflect"] { + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + let gr = json!({"kind": kind, "spread": spread, "repeats": 3, "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + // Where Repeat starts the stops again, the hard edge may land a pixel off. + assert!(mean_diff(&before, &after) < 4.0, "{kind} {spread} {to}: {}", mean_diff(&before, &after)); + } + } + } +} + +/// A gradient that repeats or reflects expands into the same whole number of objects in each run, +/// so each run starts on an object's edge: a sharp step in the middle of each of 7 runs, expanded +/// into about 15 objects (2 a run), lands where the canvas paints it. +#[test] +fn a_spread_gradient_expands_into_whole_bands_for_each_run() { + let stops = json!([ + {"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ffff00"}, + {"offset": 0.5, "color": "#004080"}, {"offset": 1, "color": "#004080"} + ]); + for kind in ["linear", "conic"] { + for spread in ["repeat", "reflect"] { + let mut s = session(); + // A conic gradient turns about the box's center. + let start = if kind == "conic" { [100, 100] } else { [30, 100] }; + let gr = json!({"kind": kind, "spread": spread, "repeats": 7, "start": start, "end": [170, 100], "stops": stops}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 15})); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{kind} {spread}: {}", mean_diff(&before, &after)); + } + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 4939327c9d..edcd1e47b3 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7658,3 +7658,144 @@ fn diamond_gradients_are_written_as_radial_gradients_through_their_corners() { let Some(Paint::Gradient(swatch)) = back.swatch("Gem").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert_eq!(swatch.gradient.kind, GradientKind::Radial); } + +/// The data has no spread: a fill that repeats or reflects holds its stops repeated across the +/// shape and reads back looking as the canvas paints it; a swatch holds its stops repeated along +/// its vector; both with a warning. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::GradientSpread; + for kind in [GradientKind::Linear, GradientKind::Radial] { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, stops) }); + let (start, end) = (vectorcraft_geom::Point::new(60.0, 50.0), vectorcraft_geom::Point::new(120.0, 50.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g.clone())); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))) + .unwrap(); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { + name: "Bands".into(), + paint: Paint::Gradient(Box::new(GradientPaint { geom: None, ..g })), + global: false, + spot: false, + }); + doc.swatches = swatches.into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!( + out.warnings.iter().any(|w| w.contains("gradients that repeat or reflect are written with their stops repeated")), + "{:?}", + out.warnings + ); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.02 * st.pixels as f64, "{kind:?}: {} of {} pixels differ", st.bad_pixels, st.pixels); + let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; + let colors: Vec = swatch.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(colors, ["#ff0000", "#0000ff", "#0000ff", "#ff0000"], "{kind:?}"); + } +} + +/// A stroke's gradient that repeats is written with its stops repeated as far as the stroke's +/// miter joins reach, past the box the gradient is placed on, so a sharp join's tip shows the next +/// run rather than the color the gradient pads with; a straight line, which has no joins, keeps +/// every run within the stops the reader reads. +#[test] +fn a_stroke_spread_gradient_runs_on_into_its_miter_tips() { + use vectorcraft_color::GradientSpread; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(GradientKind::Linear, stops) }); + // From the top of the box the gradient is placed on (the path's box grown by half the width). + let (start, end) = (vectorcraft_geom::Point::new(100.0, 15.0), vectorcraft_geom::Point::new(100.0, 85.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let mut app = Appearance::basic(Paint::None, Paint::Gradient(Box::new(g)), 10.0); + for item in &mut app.items { + if let AppearanceItem::Stroke(s) = item { + (s.join, s.miter_limit) = (LineJoin::Miter, 10.0); + } + } + // A V whose apex's miter reaches about 10 pt above it, to y = 9.9. + let pts = [vectorcraft_geom::Point::new(60.0, 90.0), vectorcraft_geom::Point::new(100.0, 20.0), vectorcraft_geom::Point::new(140.0, 90.0)]; + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]), app)).unwrap(); + let back = read_back(&doc); + let mut geom = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.stroke_paint() { + geom = g.geom; + } + }); + let geom = geom.expect("the stroke keeps a gradient"); + assert!(geom.start.y.min(geom.end.y) < 9.9, "the runs start at {:?}", geom); + // A straight line has no joins: its 100 runs, 200 stops, fit the 256 the reader reads. + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + let (start, end) = (vectorcraft_geom::Point::new(50.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let line = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[start, end], false)]); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, line, Appearance::basic(Paint::None, Paint::Gradient(Box::new(g)), 10.0))).unwrap(); + let mut stops = None; + read_back(&doc).walk(|n| { + if let Paint::Gradient(g) = n.appearance.stroke_paint() { + stops = Some(g.gradient.stops.len()); + } + }); + assert_eq!(stops, Some(200)); +} + +/// A conic fill (written linear, its stops repeated around its turn), a diamond fill (written +/// radial) and a swatch whose stops repeat more times than the 256 stops the reader reads stay +/// gradients: each holds its stops repeated as far as 256 stops go. +#[test] +fn spread_gradients_past_the_stops_the_reader_reads_stay_gradients() { + use vectorcraft_color::GradientSpread; + let three = || { + vec![ + GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), + GradientStop::new(0.5, Color::rgb(0.0, 1.0, 0.0)), + GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)), + ] + }; + for kind in [GradientKind::Conic, GradientKind::Diamond] { + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(kind, three()) }); + let c = vectorcraft_geom::Point::new(100.0, 50.0); + g.geom = Some(vectorcraft_color::GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(40.0, 0.0), aspect: 1.0, focal: None }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g)); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))) + .unwrap(); + let back = read_back(&doc); + let mut stops = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + stops = Some(g.gradient.stops.len()); + } + }); + let stops = stops.unwrap_or_else(|| panic!("{kind:?}: the gradient is written as a solid color")); + assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&stops), "{kind:?}: {stops} stops"); + } + // Stops at 0.1 and 0.9 run from 0 to 1 as four: 70 runs are 280 stops. + let stops = vec![GradientStop::new(0.1, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(0.9, Color::rgb(0.0, 0.0, 1.0))]; + let g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 70, ..Gradient::new(GradientKind::Linear, stops) }); + let mut doc = Document::new(200.0, 100.0); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Bands".into(), paint: Paint::Gradient(Box::new(g)), global: false, spot: false }); + doc.swatches = swatches.into(); + let back = read_back(&doc); + let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; + assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&swatch.gradient.stops.len()), "{} stops", swatch.gradient.stops.len()); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..35a47cc437 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -896,7 +896,7 @@ impl<'a> Writer<'a> { self.warn_cut(Some(first)); return self.clip_contents(children); }; - let items = self.items(first, path.to_bezpath().bounding_box()); + let items = self.items(first, &path.to_bezpath()); let painted = self.plain_paint(&items); // The clipping path goes after the others, so its objects are counted first. let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); @@ -1004,7 +1004,7 @@ impl<'a> Writer<'a> { if path.is_empty() { return; } - let items = self.items(n, path.to_bezpath().bounding_box()); + let items = self.items(n, &path.to_bezpath()); if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { self.warn(ITEM_TRANSPARENCY); } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 0dbe24c937..f370167bcf 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -47,6 +47,7 @@ pub(super) struct PatternOut { pub(in crate::ai_editing) const DIAMOND: &str = "diamond gradients are written as radial gradients through their corners, with the same stops, in the editing data for other apps"; +pub(in crate::ai_editing) const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (at most 256 stops, padding past them) in the editing data for other apps"; /// Can `g` be written as a gradient (a linear, radial, conic or diamond one with stops, no more /// than the `.ai` reader reads; a conic is written linear, [`CONIC`], and a diamond radial, @@ -85,11 +86,13 @@ impl Writer<'_> { pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) { let doc = self.doc; for sw in doc.swatches_iter() { + // As `swatch_paint` writes it: the stops of one that repeats or reflects repeated. if let Paint::Gradient(g) = &sw.paint - && writable_gradient(&g.gradient) + && let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS) + && writable_gradient(&gradient) && !self.gradients.iter().any(|d| d.name == sw.name) { - self.gradients.push(GradientDef { name: sw.name.clone(), gradient: g.gradient.clone(), swatch: true, used: false }); + self.gradients.push(GradientDef { name: sw.name.clone(), gradient: gradient.into_owned(), swatch: true, used: false }); } } } @@ -135,8 +138,27 @@ impl Writer<'_> { /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. pub(super) fn placed(&mut self, p: &Paint, bounds: Rect) -> Paint { + self.placed_over(p, bounds, bounds) + } + + /// [`Self::placed`] for paint that reaches `area`: a gradient that repeats or reflects is + /// written with its stops repeated over `area`. + fn placed_over(&mut self, p: &Paint, bounds: Rect, area: Rect) -> Paint { let Paint::Gradient(g) = p else { return p.clone() }; - let geom = g.resolve(bounds); + let mut geom = g.resolve(bounds); + // The data has no spread: a gradient that repeats or reflects is written with its stops + // repeated over `area` (a conic one, written linear, around its turn). + let mut g: GradientPaint = (**g).clone(); + if g.gradient.is_spread() { + self.warn(SPREAD); + match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) { + Some(padded) => { + g.gradient = padded.gradient; + geom = padded.geom; + } + None => g.gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS).into_owned(), + } + } let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite()); let radial = written_radial(g.gradient.kind); if g.gradient.kind == GradientKind::Freeform { @@ -147,13 +169,16 @@ impl Writer<'_> { self.warn(GRADIENT_SHAPES); return Paint::solid(average(&g.gradient)); } - Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() })) + Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..g })) } - /// The fills and strokes of `n` that paint (outline `bounds`), their gradients placed - /// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints. A gradient - /// along or across a stroke is its average color (the stroke is written as its outline). - pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bounds: Rect) -> Vec { + /// The fills and strokes of `n` that paint (outline `bp`), their gradients placed + /// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints, the stops + /// of one that repeats or reflects repeated over the box the stroke paints, miter spikes + /// included ([`StrokeLayer::bounds_on`]). A gradient along or across a stroke is its average + /// color (the stroke is written as its outline). + pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bp: &BezPath) -> Vec { + let bounds = bp.bounding_box(); super::visible_items(n) .into_iter() .map(|i| match i { @@ -164,7 +189,11 @@ impl Writer<'_> { self.warn(GRADIENTS); Paint::solid(average(&g.gradient)) } - None => self.placed(&s.paint, s.paint_bounds(bounds)), + None => { + let spread = matches!(&s.paint, Paint::Gradient(g) if g.gradient.is_spread()); + let reach = if spread { s.bounds_on(bp, bounds) } else { bounds }; + self.placed_over(&s.paint, s.paint_bounds(bounds), reach) + } }; AppearanceItem::Stroke(StrokeLayer { paint, ..s.clone() }) } @@ -521,10 +550,19 @@ impl Writer<'_> { /// `(name) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p`. False for other paint, and for paint that can't /// be written. pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool { + // A swatch has no placement: one that repeats or reflects holds its stops repeated along + // its vector. + let unrolled = match &sw.paint { + Paint::Gradient(g) => Some(g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)), + _ => None, + }; match &sw.paint { - Paint::Gradient(g) if writable_gradient(&g.gradient) => { + Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => { + if g.gradient.is_spread() { + self.warn(SPREAD); + } let swatch = Some(sw.name.as_str()); - let name = self.define_gradient(&g.gradient, swatch); + let name = self.define_gradient(unrolled.as_deref().unwrap_or(&g.gradient), swatch); s.push_str("Bb\n"); if written_radial(g.gradient.kind) { s.push_str("0 0 0 0 Bh\n"); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 5e058acaf6..bf4798dd26 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -110,6 +110,8 @@ const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the const FREEFORM: &str = "freeform gradients are written as their average colour"; const CONIC: &str = "conic gradients are written as solid wedges clipped to the shape (legacy .ai files have no conic gradient)"; const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; +const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)"; +const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 5528ad7529..3882e37fe1 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -41,8 +41,12 @@ pub(super) fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) s.push_str(" 3 "); } } - ps::push_nums(s, &[f64::from(stop.midpoint) * 100.0, f64::from(stop.offset) * 100.0]); - s.push_str("%_Bs\n"); + // The ramp point to five decimals of its percent: a gradient's stops repeated many times + // keep their places in each run. + ps::push_num(s, f64::from(stop.midpoint) * 100.0); + s.push(' '); + ps::push_num_places(s, f64::from(stop.offset) * 100.0, 5); + s.push_str(" %_Bs\n"); } s.push_str("BD\n%AI5_EndGradient\n"); } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index b9ffcd0305..b8605c2680 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -110,6 +110,119 @@ fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() { } } +/// The format's gradients pad past their ends: a linear gradient that repeats or reflects is +/// written with its stops repeated across the shape (a definition of its own, not named after the +/// swatch), and a conic one as wedges that repeat with the stops (which print as the canvas +/// draws them), with a warning. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let linear = |spread| { + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(GradientKind::Linear, stops.clone()) }); + // Across the shape: three runs. + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(20.0, 50.0), + end: vectorcraft_geom::Point::new(180.0, 50.0), + aspect: 1.0, + focal: None, + }); + g.swatch = Some("Bands".into()); + Paint::Gradient(Box::new(g)) + }; + for (spread, colors) in [ + (GradientSpread::Repeat, ["0 0 1", "1 0 0", "0 0 1", "1 0 0", "0 0 1", "1 0 0"]), + (GradientSpread::Reflect, ["0 0 1", "1 0 0", "1 0 0", "0 0 1", "0 0 1", "1 0 0"]), + ] { + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), linear(spread), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == SPREAD), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes); + assert!(text.contains("(Gradient) 0 6 Bd") && !text.contains("(Bands)"), "{text}"); + // A linear definition lists its stops from the last to the first, each RGB stop as + // `c m y k r g b 2 …`. + let def = text.split("(Gradient) 0 6 Bd\n[\n").nth(1).unwrap(); + for (line, rgb) in def.lines().take(6).zip(colors) { + assert!(line.contains(&format!(" {rgb} 2 ")), "{spread:?}: {line} lacks {rgb}\n{def}"); + } + } + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Conic, stops) }); + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 1.0, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let text = String::from_utf8_lossy(&write(&doc).bytes).into_owned(); + assert!(text.matches("\nf\n").count() >= 3 * CONIC_WEDGES, "a wedge a degree for each run"); +} + +/// A gradient repeated more times than the reader keeps stops for is written with no more stops +/// than it reads, with a warning, so it opens with every stop that was written. +#[test] +fn many_repeats_are_written_within_the_stops_the_reader_reads() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + // A run of 0.4 pt across a shape 160 pt wide: 400 runs. + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(20.0, 50.0), + end: vectorcraft_geom::Point::new(60.0, 50.0), + aspect: 1.0, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == SPREAD_CAPPED), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let written = def.matches(" %_Bs\n").count(); + assert!((2..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{written} stops"); + let read = crate::import(&out.bytes).unwrap().document; + let mut stops = None; + read.walk(|n| { + if let (None, Paint::Gradient(g)) = (&stops, n.appearance.fill_paint()) { + stops = Some(g.gradient.stops.len()); + } + }); + assert_eq!(stops, Some(written), "every stop written is read"); +} + +/// A radial gradient repeated out from a center far from the shape is written with its ramp points +/// (percent) to five decimals, so its runs keep their places hundreds of runs out. +#[test] +fn far_runs_keep_their_places() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Radial, stops) }); + // Runs 0.25 pt apart, the shape 480 runs and more from the center. + let c = vectorcraft_geom::Point::new(-100.0, 50.0); + g.geom = Some(GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(0.25, 0.0), aspect: 1.0, focal: None }); + let bounds = Rect::new(20.0, 10.0, 180.0, 90.0); + let padded = g.gradient.padded_within(&g.geom.unwrap(), bounds, crate::import::MAX_GRADIENT_STOPS).unwrap(); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(bounds), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let text = String::from_utf8_lossy(&write(&doc).bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let ramps: Vec = def.lines().filter_map(|l| l.strip_suffix(" %_Bs")).map(|l| l.rsplit(' ').next().unwrap().parse().unwrap()).collect(); + assert_eq!(ramps.len(), padded.gradient.stops.len()); + for (ramp, stop) in ramps.iter().zip(&padded.gradient.stops) { + assert!((ramp / 100.0 - f64::from(stop.offset)).abs() < 2e-7, "{ramp} for {}", stop.offset); + } +} + /// The format has no diamond gradient: a diamond fill, and a diamond stroke (written as its filled /// outline), are four path objects clipped to the shape, each a linear gradient over a quadrant /// of the diamond's frame, with a warning; the definition is not named after the swatch. @@ -139,6 +252,32 @@ fn diamond_gradients_are_written_as_four_linear_gradients_clipped_to_the_shape() } } +/// A diamond gradient that repeats is four linear gradients over the quadrants of its frame, each +/// with its stops repeated out to the shape's edge, with a warning. +#[test] +fn a_spread_diamond_repeats_in_each_quadrant() { + use vectorcraft_color::GradientSpread; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(GradientKind::Diamond, stops) }); + g.geom = Some(vectorcraft_color::GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 0.7, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DIAMOND) && out.warnings.iter().any(|w| w == SPREAD), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + assert_eq!(text.matches(" Bg\n").count(), 4, "a gradient a quadrant"); + let defs: Vec = + text.split("%AI5_BeginGradient: ").skip(1).map(|d| d.split("%AI5_EndGradient").next().unwrap_or("").matches(" %_Bs\n").count()).collect(); + assert!(!defs.is_empty() && defs.iter().all(|&n| n >= 6), "stops in each definition: {defs:?}"); +} + /// `doc` written with options `o`. Every file the tests write is also run as a PostScript program /// ([`prints_as`]) and must draw what `doc` draws. fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 3373330892..548a8dfda8 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -9,7 +9,7 @@ use super::{ ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; -use super::{CONIC, CONIC_WEDGES, DIAMOND}; +use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -481,7 +481,8 @@ impl<'a> Writer<'a> { self.warn(TRANSPARENCY); } let area = path.to_bezpath().bounding_box(); - let wedges = g.resolve(area).conic_wedges(area, CONIC_WEDGES, true); + let wedges = g.resolve(area).conic_wedges(area, g.gradient.conic_wedge_count(CONIC_WEDGES), true); + let spread = g.gradient.spreader(); self.line("q"); self.fill_rule(rule); let several = path.subpaths.len() > 1; @@ -495,7 +496,7 @@ impl<'a> Writer<'a> { self.line("*U"); } for (pts, t) in wedges { - let (color, _) = g.gradient.sample_keep_model(t as f32); + let (color, _) = g.gradient.sample_keep_model(spread(t) as f32); let wedge = FillLayer { paint: Paint::solid(color), ..f.clone() }; self.path_object(&PathData::single(SubPath::polyline(&pts, true)), FillRule::NonZero, Some(&wedge), None); } @@ -672,6 +673,22 @@ impl<'a> Writer<'a> { /// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center, /// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`. fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String { + // A gradient that repeats or reflects (the format's gradients pad past their ends): its + // stops repeated as many times as `bounds` needs, in a definition of its own, no more + // stops than the reader reads. + if let Some(padded) = g.gradient.padded_within(&g.resolve(bounds), bounds, crate::import::MAX_GRADIENT_STOPS) { + self.warn(if padded.capped { SPREAD_CAPPED } else { SPREAD }); + let geom = padded.geom; + let p = GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } + // One whose levels over `bounds` are not finite (a vector of no length): its stops + // repeated along the vector, padding past it. + if g.gradient.is_spread() { + self.warn(SPREAD_CAPPED); + let p = GradientPaint { gradient: g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS).into_owned(), swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } let name = self.define(&g.gradient, g.swatch.as_deref()); for stop in &g.gradient.stops { if stop.opacity < 1.0 { diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index 3f4b1049f5..8fef6d75dd 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -33,11 +33,19 @@ pub(crate) fn num(v: f64) -> String { /// Append [`num`]`(v)` to `s`. pub(crate) fn push_num(s: &mut String, v: f64) { - // Beyond ~1.8e304 the scaling overflows; such values have no fractional digits to round. + push_num_places(s, v, 4); +} + +/// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not +/// finite). +pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) { + let places = places.min(9); + let scale = 10f64.powi(places as i32); + // Past where the scaling overflows, values have no fractional digits to round. let v = if !v.is_finite() { 0.0 - } else if (v * 10_000.0).is_finite() { - (v * 10_000.0).round() / 10_000.0 + } else if (v * scale).is_finite() { + (v * scale).round() / scale } else { v }; @@ -46,7 +54,7 @@ pub(crate) fn push_num(s: &mut String, v: f64) { return; } let start = s.len(); - let _ = write!(s, "{v:.4}"); + let _ = write!(s, "{v:.*}", places as usize); let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); s.truncate(start + keep); } @@ -291,7 +299,7 @@ pub(crate) fn inflate(data: &[u8], max: u64) -> Option> { #[cfg(test)] mod tests { - use super::{ascii85, ascii85_decode, num}; + use super::{ascii85, ascii85_decode, num, push_num_places}; #[test] fn ascii85_first_line_never_starts_with_percent() { @@ -308,4 +316,18 @@ mod tests { assert!(!s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.'), "{v}: got {s:?}"); } } + + #[test] + fn numbers_keep_the_decimals_asked_for() { + let places = |v: f64, n: u32| { + let mut s = String::new(); + push_num_places(&mut s, v, n); + s + }; + assert_eq!(num(0.123_456_78), "0.1235"); + assert_eq!(places(0.123_456_78, 7), "0.1234568"); + assert_eq!(places(f64::from(0.4f32), 7), "0.4"); + assert_eq!(places(2.5, 7), "2.5"); + assert_eq!(places(f64::NAN, 7), "0"); + } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 96205aa7d3..5cc90117d7 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -44,8 +44,12 @@ const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a Dev const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)"; const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)"; +const SPREAD_CAPPED: &str = + "gradients that repeat or reflect are written with at most 256 runs of their stops (at most 16384 stops); they pad past those"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGE_COUNT: usize = 360; +/// Decimals of a smooth shading's stitching function bounds (stop offsets, 0 to 1). +const BOUNDS_PLACES: u32 = 7; /// The most colorants a DeviceN color space may have (PostScript's limit). const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; @@ -940,6 +944,23 @@ impl<'a> Scene<'a> { /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]). fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { let geom = g.resolve(bounds); + // A linear, radial or diamond gradient that repeats or reflects: PostScript's shadings + // pad past their ends, so its stops are repeated as many times as the area needs. + if let Some(padded) = g.gradient.padded_over(&geom, area) { + if padded.capped { + self.warn(SPREAD_CAPPED); + } + let geom = padded.geom; + let paint = GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), ..g.clone() }; + return self.gradient(&paint, bounds, area); + } + // One whose levels over the area are not finite (a vector of no length): its stops + // repeated along the vector, padding past it. + if g.gradient.is_spread() && g.gradient.kind != GradientKind::Conic { + self.warn(SPREAD_CAPPED); + let paint = GradientPaint { gradient: g.gradient.unrolled().into_owned(), ..g.clone() }; + return self.gradient(&paint, bounds, area); + } let (space, stops) = self.gradient_stops(&g.gradient); if !stops.windows(2).any(|w| w[1].0 > w[0].0) { if let Some((_, c)) = stops.first() { @@ -951,7 +972,7 @@ impl<'a> Scene<'a> { let space = &space; let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; match g.gradient.kind { - GradientKind::Conic => self.conic_steps(&geom, area, space, &stops), + GradientKind::Conic => self.conic_steps(&geom, area, space, &stops, &g.gradient), GradientKind::Diamond => self.diamond_quadrants(&geom, area, space, &stops, smooth), GradientKind::Radial => { let r = geom.length().max(1e-6); @@ -984,15 +1005,24 @@ impl<'a> Scene<'a> { /// A conic gradient as [`CONIC_WEDGE_COUNT`] solid wedges about its center covering `area` /// (PostScript has no conic shading), each running on to the seam under the next, so no /// hairline shows between neighbors ([`vectorcraft_color::GradientGeom::conic_wedges`]). - fn conic_steps(&mut self, geom: &vectorcraft_color::GradientGeom, area: Rect, space: &GradientSpace, stops: &[(f64, Vec)]) { + /// Stops of `g` that repeat or reflect get that many wedges for each time they run around. + fn conic_steps( + &mut self, + geom: &vectorcraft_color::GradientGeom, + area: Rect, + space: &GradientSpace, + stops: &[(f64, Vec)], + g: &vectorcraft_color::Gradient, + ) { self.warn(CONIC_WEDGES); + let spread = g.spreader(); let at = |t: f64| match stops.windows(2).find(|w| t <= w[1].0) { Some(w) if w[1].0 > w[0].0 => mix(&w[0].1, &w[1].1, ((t - w[0].0) / (w[1].0 - w[0].0)).clamp(0.0, 1.0) as f32), Some(w) => w[1].1.clone(), None => stops.last().map_or_else(Vec::new, |s| s.1.clone()), }; - for (pts, t) in geom.conic_wedges(area, CONIC_WEDGE_COUNT, true) { - self.set_comps(space, &at(t)); + for (pts, t) in geom.conic_wedges(area, g.conic_wedge_count(CONIC_WEDGE_COUNT), true) { + self.set_comps(space, &at(spread(t))); for (i, p) in pts.iter().enumerate() { push_nums(&mut self.body, &[p.x, p.y]); self.line(if i == 0 { "newpath moveto" } else { "lineto" }); @@ -1059,8 +1089,12 @@ impl<'a> Scene<'a> { f.push('\n'); } f.push_str("] /Bounds ["); - let bounds: Vec = many.iter().skip(1).map(|w| w[0].0).collect(); - push_nums(&mut f, &bounds); + // Seven decimals: a gradient's stops repeated hundreds or thousands of times + // (`Gradient::padded_over`) keep their places in each run. + for w in many.iter().skip(1) { + ps::push_num_places(&mut f, w[0].0, BOUNDS_PLACES); + f.push(' '); + } f.push_str("] /Encode ["); f.push_str(&"0 1 ".repeat(many.len())); f.push_str("] >>"); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index db9ae2837c..b503421eb7 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -244,6 +244,82 @@ fn diamond_gradients_are_four_linear_gradients_clipped_to_quadrants() { ); } +/// PostScript's shadings pad past their ends: a linear, radial or diamond gradient that repeats or +/// reflects is written with its stops repeated over the shape, a conic one as wedges that repeat +/// with the stops; read back from the PostScript, each looks as the canvas paints it, at Level 3 +/// and Level 2. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let c = Point::new(100.0, 100.0); + let around = GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(60.0, -25.0), aspect: 0.7, focal: None }; + let cases = [ + (GradientKind::Linear, GradientGeom { start: Point::new(70.0, 80.0), end: Point::new(150.0, 110.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, around), + (GradientKind::Radial, GradientGeom { focal: Some(c + vectorcraft_geom::Vec2::new(-15.0, 12.0)), ..around }), + (GradientKind::Conic, around), + (GradientKind::Diamond, around), + ]; + for (kind, geom) in cases { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(kind, stops) }); + g.geom = Some(geom); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint, Paint::None, 0.0)]); + for o in [opts(&d), EpsOptions { level: Level::Two, ..opts(&d) }] { + let out = export(&d, &o, None, None).unwrap(); + let back = import_with(&out.bytes, false).unwrap().document; + let (a, b) = (vectorcraft_testkit::raster::render_artboard(&d), vectorcraft_testkit::raster::render_artboard(&back)); + let st = vectorcraft_testkit::raster::perceptual_diff(&a, &b, 12.0); + assert!( + (st.bad_pixels as f64) < 0.03 * st.pixels as f64, + "{kind:?} {spread:?} level {:?}: {} of {} pixels differ", + o.level, + st.bad_pixels, + st.pixels + ); + } + } + } +} + +/// A gradient that repeats with no stops (a file can hold one) is written as one that pads, to +/// EPS, legacy `.ai` and the `.ai` editing data. +#[test] +fn a_spread_gradient_without_stops_is_written() { + use vectorcraft_color::GradientSpread; + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(kind, vec![]) }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint.clone(), paint, 4.0)]); + export(&d, &opts(&d), None, None).unwrap(); + export_legacy_ai(&d, &LegacyAiOptions::default()).unwrap(); + crate::export_ai_editing(&d, &crate::AiEditingOptions::default()).unwrap(); + } +} + +/// A gradient repeated 256 times across the shape keeps each run's place: the smooth shading's +/// stitching bounds lie where the runs meet, to well under a run's thousandth. +#[test] +fn many_repeats_keep_their_places_in_a_smooth_shading() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + // A run of 0.4 pt across a shape 160 pt wide: capped at 256 runs. + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + g.geom = Some(GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(60.0, 100.0), aspect: 1.0, focal: None }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint, Paint::None, 0.0)]); + let page = page_of(&d, &opts(&d)); + let bounds: Vec = + page.split("/Bounds [").nth(1).and_then(|s| s.split(']').next()).unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + let runs = (bounds.len() + 1) as f64; + assert_eq!(runs, 256.0, "{bounds:?}"); + for b in bounds { + assert!((b * runs - (b * runs).round()).abs() < 1e-3, "bound {b} is {} runs along", b * runs); + } +} + #[test] fn strokes_keep_their_caps_joins_and_dashes() { let d = doc_with(|d| { diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 343b422093..ac5cf183cd 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -415,13 +415,13 @@ impl<'a> Scene<'a> { field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() } else { let geom = g.resolve(bounds); - let sample = g.gradient.sampler(); + let (sample, spread) = (g.gradient.sampler(), g.gradient.spreader()); let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows)); (0..usize::from(rows) * usize::from(cols)) .flat_map(|i| { let (x, y) = (i % usize::from(cols), i / usize::from(cols)); let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch); - let t = geom.param_at(g.gradient.kind, p); + let t = spread(geom.param_at(g.gradient.kind, p)); let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; let (c, a) = sample(t as f32); c.to_rgba8(a) diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 90b7ae5248..2d4113a1fa 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -434,8 +434,9 @@ impl Exporter<'_> { /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of spot /// inks ([`Self::perceptual_spot`]), a conic gradient that can't be a sweep shading (in - /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), or a diamond gradient - /// (four linear gradients, [`Self::diamond`])? + /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), a diamond gradient + /// (four linear gradients, [`Self::diamond`]), or a linear or radial gradient that repeats or + /// reflects (its stops repeated over the area, [`Self::padded`])? pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), @@ -444,6 +445,7 @@ impl Exporter<'_> { (g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill)) || (g.gradient.kind == GradientKind::Conic && sweep_unwritable()) || g.gradient.kind == GradientKind::Diamond + || (g.gradient.is_spread() && matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial)) } _ => false, } @@ -452,7 +454,8 @@ impl Exporter<'_> { /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), a diamond - /// gradient's four linear gradients, or an image of a gradient drawn as pixels, placed on + /// gradient's four linear gradients, a repeating or reflecting linear or radial gradient's + /// stops repeated over `area`, or an image of a gradient drawn as pixels, placed on /// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn) /// for other paints. #[allow(clippy::too_many_arguments)] @@ -504,7 +507,9 @@ impl Exporter<'_> { } Paint::Gradient(g) => { let pixels = g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill); - if pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area) { + // A linear or radial gradient that repeats or reflects is drawn by `padded`. + let drawn = !pixels && self.padded(s, g, bounds, area); + if !drawn && (pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area)) { self.field_image(s, g, bounds, area, fill); } } diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 38ab52f182..acb96f1713 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -236,6 +236,24 @@ impl Exporter<'_> { Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); + // A linear or radial gradient that repeats or reflects, where it isn't an area + // paint ([`Self::padded`]): its stops repeated over `bounds`, padding past them. + if matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial) && g.gradient.is_spread() { + let paint = match g.gradient.padded_over(&geom, bounds) { + Some(padded) => { + self.warn_capped(padded.capped); + padded_paint(g, padded) + } + // Levels over `bounds` that are not finite (a vector of no length): the + // stops repeated along the vector, padding past it. + None => { + self.warn_capped(true); + let gradient = g.gradient.unrolled().into_owned(); + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient, ..(**g).clone() })) + } + }; + return self.paint_in(s, &paint, bounds, space); + } let spot = match g.gradient.kind { GradientKind::Freeform => None, _ => self.perceptual_spot_stops(&g.gradient).or_else(|| self.spot_stops(&g.gradient)), @@ -318,20 +336,27 @@ impl Exporter<'_> { } // A function-based shading (ISO 32000-1, 8.7.4.5.2) whose calculator function // turns the angle about the origin into the ramp, once around (0 to 360 - // degrees), carried onto the extent ellipse by its frame. + // degrees), carried onto the extent ellipse by its frame. Stops that repeat + // or reflect `n` times run over 360/`n` degrees, the function repeating or + // reflecting them past it. GradientKind::Conic => { let frame = geom.conic_frame(); if !writable(s, space * frame, Rect::new(-1.0, -1.0, 1.0, 1.0)) { return None; } + let (end_angle, spread_method) = match g.gradient.spread { + _ if !g.gradient.is_spread() => (360.0, SpreadMethod::Pad), + vectorcraft_color::GradientSpread::Reflect => (360.0 / g.gradient.repeat_count() as f32, SpreadMethod::Reflect), + _ => (360.0 / g.gradient.repeat_count() as f32, SpreadMethod::Repeat), + }; Some( SweepGradient { cx: 0.0, cy: 0.0, start_angle: 0.0, - end_angle: 360.0, + end_angle, transform: xf(space * frame), - spread_method: SpreadMethod::Pad, + spread_method, stops, anti_alias: false, } @@ -484,15 +509,27 @@ impl Exporter<'_> { /// linear gradients, one for each quadrant of its frame, each equal to the diamond there (PDF /// has no diamond shading; [`vectorcraft_color::GradientGeom::diamond_quadrants`]). The /// quadrants overlap so no hairline shows between them, which needs opaque stops. False - /// (nothing drawn) for a diamond with translucent stops or without a frame, which + /// (nothing drawn) for a diamond with translucent stops or without a frame, for one with a + /// quadrant whose shading or outline PDF can't hold, and for a Perceptual one of spot inks + /// whose ink curve has too many tints once its stops are repeated, which /// [`Self::field_image`] draws instead. pub(super) fn diamond(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool { if g.gradient.stops.iter().any(|st| st.opacity < 1.0) { return false; } - let Some(quadrants) = g.resolve(bounds).diamond_quadrants(area.abs()) else { return false }; - self.warn("diamond gradients are exported as four linear gradients"); - let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..g.gradient.clone() }; + // A diamond that repeats or reflects: its stops repeated over the levels the area reaches + // from the center, then split into quadrants, each of which runs those levels. + let (diamond, geom, capped) = match g.gradient.padded_over(&g.resolve(bounds), area.abs()) { + Some(padded) => (padded.gradient, padded.geom, padded.capped), + None => (g.gradient.clone(), g.resolve(bounds), false), + }; + // A Perceptual one of spot inks whose ink curve no longer fits with its stops repeated. + if !diamond.method.is_classic() && !diamond.fits_ink_tints() { + return false; + } + let Some(quadrants) = geom.diamond_quadrants(area.abs()) else { return false }; + let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..diamond }; + let mut drawn = Vec::with_capacity(4); for (outline, geom) in quadrants { let linear = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: gradient.clone(), geom: Some(geom), ..g.clone() })); let quadrant = kurbo::BezPath::from_vec( @@ -503,16 +540,61 @@ impl Exporter<'_> { .chain([kurbo::PathEl::ClosePath]) .collect(), ); - if let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) { - s.set_stroke(None); - s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&path); - s.set_fill(None); - } + let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) else { return false }; + drawn.push((paint, path)); + } + self.warn("diamond gradients are exported as four linear gradients"); + self.warn_capped(capped); + for (paint, path) in drawn { + s.set_stroke(None); + s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + s.set_fill(None); } true } + /// Draw linear or radial gradient `g`, placed on `bounds`, that repeats or reflects, over + /// `area` (inside the current clip): PDF's axial and radial shadings pad past their ends, so + /// its stops are repeated as many times as the area needs + /// ([`vectorcraft_color::Gradient::padded_over`]). False (nothing drawn) for another gradient, + /// for one whose shading or area PDF can't hold, and for a Perceptual one of spot inks whose + /// ink curve has too many tints once repeated, which [`Self::field_image`] draws instead. + pub(super) fn padded(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool { + if !matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial) { + return false; + } + let Some(padded) = g.gradient.padded_over(&g.resolve(bounds), area.abs()) else { return false }; + // A Perceptual gradient of spot inks whose ink curve no longer fits once its stops are + // repeated is an image, as one with too many stops is. + if !g.gradient.method.is_classic() && !padded.gradient.fits_ink_tints() { + return false; + } + let capped = padded.capped; + let paint = padded_paint(g, padded); + let (Some(paint), Some(path)) = (self.paint(s, &paint, bounds), to_path(s, &kurbo::Shape::to_path(&area.abs(), 0.1))) else { + return false; + }; + self.warn_capped(capped); + s.set_stroke(None); + s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + s.set_fill(None); + true + } + + /// Warn when a gradient that repeats or reflects needed more runs of its stops than were + /// written. + fn warn_capped(&mut self, capped: bool) { + if capped { + self.warn(format!( + "gradients that repeat or reflect are exported with at most {} runs of their stops (at most {} stops); they pad past those", + vectorcraft_color::gradient::MAX_SPREAD_RUNS, + vectorcraft_color::gradient::MAX_SPREAD_STOPS + )); + } + } + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most @@ -588,6 +670,12 @@ impl Exporter<'_> { } } +/// `g` written as `padded` gives it: its stops repeated, placed there. +fn padded_paint(g: &vectorcraft_color::GradientPaint, padded: vectorcraft_color::gradient::Padded) -> Paint { + let geom = padded.geom; + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), ..g.clone() })) +} + /// Is `c` paper white: no ink in CMYK or gray, white in RGB or Lab? fn paper(c: &Color) -> bool { match *c { diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index 2cafbf010a..2a734174ce 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -1,5 +1,6 @@ //! Shadings: axial and radial ones become gradients (their stops sampled from the shading -//! function in its own colour model; an end that doesn't extend clips the painted area), a +//! function in its own color model, sharp steps between colors kept where they are; an end that +//! doesn't extend clips the painted area), a //! function-based one whose color changes along one axis a linear gradient, one whose color //! turns around the origin of its space a conic gradient, and one whose color is the same along //! each square about that origin a diamond gradient (else its average color; all of them @@ -41,13 +42,21 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components(); Some(Color::rgb(round3(r), round3(g), round3(b))) }; - // Sample densely, then drop samples that linear interpolation reproduces. + // Sample densely, halving each span between samples where its middle strays from the line + // between them (so a sharp step between two colors, as repeated stops make, is found to + // within [`EDGE_DEPTH`] halvings), then drop samples that linear interpolation reproduces. const N: usize = 64; let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1); for i in 0..=N { let t = i as f32 / N as f32; - pts.push((t, sample(t)?.to_rgb())); - // An ink that paints only at samples dropped below is still a spot swatch. + let rgb = sample(t)?.to_rgb(); + if let Some(&(t0, c0)) = pts.last() { + refine(&sample, (t0, c0), (t, rgb), 0, &mut pts); + } + pts.push((t, rgb)); + } + // An ink that paints only at samples dropped below is still a spot swatch. + for &(t, _) in &pts { if let Some(v) = at(t) { colors.mark_inks(space, &v); } @@ -92,6 +101,37 @@ fn shading_stop(colors: &mut Colors<'_>, space: Space, comps: Option<&[f32]>, of } } +/// How many times [`refine`] halves a span of an axial or radial shading's samples: to 1/64 × +/// 2^-14 of the axis. +const EDGE_DEPTH: u32 = 14; +/// The most samples of one axial or radial shading [`refine`] takes. +const MAX_EDGE_SAMPLES: usize = 16_384; +/// Where in a span [`refine`] looks a second time when the span's middle lies on the line: at +/// the golden section, which no whole number of repeated steps across the span puts on the same +/// place in a step as the ends. +const SECOND_LOOK: f32 = 0.381_966; + +/// Samples of `sample` between `a` and `b` (offset, RGB), pushed onto `out` in order: the span's +/// middle where it, or the point [`SECOND_LOOK`] along the span, strays from the line between +/// them by more than 1.5 levels, and the samples of each half the same way, at most +/// [`EDGE_DEPTH`] halvings deep and [`MAX_EDGE_SAMPLES`] in all. The second look finds steps that +/// repeat a whole number of times across the span, which put its ends and its middle at the same +/// place in a step. +fn refine(sample: &dyn Fn(f32) -> Option, a: (f32, [f32; 3]), b: (f32, [f32; 3]), depth: u32, out: &mut Vec<(f32, [f32; 3])>) { + if depth >= EDGE_DEPTH || out.len() >= MAX_EDGE_SAMPLES { + return; + } + let on_line = |u: f32, c: [f32; 3]| (0..3).all(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - c[k]).abs() <= 1.5 / 255.0); + let t = (a.0 + b.0) / 2.0; + let Some(c) = sample(t).map(|c| c.to_rgb()) else { return }; + if on_line(0.5, c) && sample(a.0 + (b.0 - a.0) * SECOND_LOOK).is_none_or(|p| on_line(SECOND_LOOK, p.to_rgb())) { + return; + } + refine(sample, a, (t, c), depth + 1, out); + out.push((t, c)); + refine(sample, (t, c), b, depth + 1, out); +} + /// The samples of `pts` (offset, RGB; at least one) a gradient keeps as stops: the first, the /// last, and each one that linear interpolation between its neighbouring stops doesn't reproduce. fn linear_keep(pts: &[(f32, [f32; 3])]) -> Vec { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d8238d8038..94a20146f2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -45,6 +45,7 @@ mod reader_compat; mod reader_streaming; mod realtext; mod settings; +mod spread; pub(super) mod stroke; mod strokeout; mod text_placement; diff --git a/crates/pdf/src/tests/spread.rs b/crates/pdf/src/tests/spread.rs new file mode 100644 index 0000000000..ebfe655f9a --- /dev/null +++ b/crates/pdf/src/tests/spread.rs @@ -0,0 +1,260 @@ +//! Gradients that repeat or reflect. PDF's axial and radial shadings pad past their ends, so a +//! linear, radial or diamond gradient is written with its stops repeated as many times as the +//! shape needs (axial and radial shadings, which PDF/A allows); a conic's sweep shading runs over +//! one run and repeats or reflects it. Read back, each draws as the canvas draws the gradient. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_testkit::raster::render_artboard; + +use crate::*; + +/// A page-sized square filled (or stroked 16 pt wide, `stroke`) with a red-to-blue gradient of +/// `kind` placed on `geom`, spreading by `spread` 3 times. +fn doc(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, stroke: bool) -> Document { + doc_n(kind, spread, geom, stroke, 3) +} + +/// [`doc`], spreading `repeats` times. +fn doc_n(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, stroke: bool, repeats: u32) -> Document { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut gp = GradientPaint::new(Gradient { spread, repeats, ..Gradient::new(kind, stops) }); + gp.geom = Some(geom); + let paint = Paint::Gradient(Box::new(gp)); + let ap = if stroke { Appearance::basic(Paint::None, paint, 16.0) } else { Appearance::basic(paint, Paint::None, 0.0) }; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + d +} + +fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> { + let c = Point::new(100.0, 100.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(60.0, -25.0), aspect: 0.7, focal: None }; + vec![ + ("linear", GradientKind::Linear, GradientGeom { start: Point::new(70.0, 80.0), end: Point::new(150.0, 110.0), aspect: 1.0, focal: None }), + ("radial", GradientKind::Radial, geom), + ("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-15.0, 12.0)), ..geom }), + ("conic", GradientKind::Conic, geom), + ("diamond", GradientKind::Diamond, geom), + ] +} + +/// Does the PDF text hold an image XObject? +fn image(text: &str) -> bool { + text.contains("/Subtype/Image") || text.contains("/Subtype /Image") +} + +fn uncompressed(d: &Document, o: &PdfOptions) -> String { + String::from_utf8_lossy(&export_with_report(d, o).unwrap().bytes).into_owned() +} + +/// Every kind, filled and stroked, repeating and reflecting, written and read back, looks as the +/// canvas paints it, inside the vector and past its ends. +#[test] +fn spread_gradients_come_back_as_the_canvas_draws_them() { + let _held = super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for (what, kind, geom) in cases() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for stroke in [false, true] { + let d = doc(kind, spread, geom, stroke); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + // Hard edges (where Repeat starts the stops again) differ by a pixel's + // antialiasing at most. + let (a, b) = (render_artboard(&back), render_artboard(&d)); + let st = vectorcraft_testkit::raster::perceptual_diff(&a, &b, 10.0); + assert!( + (st.bad_pixels as f64) < 0.03 * st.pixels as f64, + "{what} {spread:?} stroke {stroke}: {} of {} pixels differ", + st.bad_pixels, + st.pixels + ); + } + } + } +} + +/// A linear, radial or diamond gradient is written as axial or radial shadings (in PDF/A too); a +/// conic as its sweep shading. +#[test] +fn the_shadings_written() { + let mut pdfa = PdfOptions::uncompressed(); + (pdfa.settings.standard, pdfa.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version()); + for (what, kind, geom) in cases() { + for o in [PdfOptions::uncompressed(), pdfa.clone()] { + let text = uncompressed(&doc(kind, GradientSpread::Reflect, geom, false), &o); + let want = match kind { + GradientKind::Linear | GradientKind::Diamond => "/ShadingType 2", + GradientKind::Radial => "/ShadingType 3", + _ => "/ShadingType 1", + }; + if kind == GradientKind::Conic && o.settings.standard != Standard::None { + // PDF/A has no calculator functions: the sweep is an image. + continue; + } + assert!(text.contains(want), "{what}: {want}"); + assert!(!image(&text), "{what}: no image"); + } + } +} + +/// An axial shading with a sharp step between two colors reads back with the step where it was, +/// not blurred across the samples around it. +#[test] +fn a_sharp_step_in_a_shading_reads_back_sharp() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let stops = vec![GradientStop::new(0.0, red), GradientStop::new(0.37, red), GradientStop::new(0.37, blue), GradientStop::new(1.0, blue)]; + let mut gp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + gp.geom = Some(GradientGeom { start: Point::new(0.0, 100.0), end: Point::new(200.0, 100.0), aspect: 1.0, focal: None }); + let mut d = Document::new(200.0, 200.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap(); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = None; + back.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let g = found.expect("a gradient"); + let geom = g.geom.unwrap(); + for (x, want) in [(73.0, red), (72.0, red), (75.0, blue), (76.0, blue)] { + let t = geom.param_at(GradientKind::Linear, Point::new(x, 100.0)); + let got = g.gradient.sample(t as f32).0.to_rgb(); + assert!(got.iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 0.02), "x {x}: {got:?}"); + } +} + +/// A gradient written with 256 runs of its stops (as many as PDF export writes), so that every +/// sample the reader takes across the shading lands at the start of a run, reads back with its +/// runs rather than as one color. +#[test] +fn many_runs_read_back_as_runs() { + let _held = super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // A run of 0.4 pt from the shape's left edge, across 160 pt: capped at 256 runs, 102.4 pt. + let geom = GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(60.0, 100.0), aspect: 1.0, focal: None }; + let d = doc_n(GradientKind::Linear, GradientSpread::Repeat, geom, false, 100); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = None; + back.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let g = found.expect("a gradient"); + let geom = g.geom.unwrap(); + for run in [0.0, 1.0, 50.0, 200.0, 255.0] { + for (u, want) in [(0.25, [0.75, 0.0, 0.25]), (0.75, [0.25, 0.0, 0.75])] { + let x = 20.0 + 0.4 * (run + u); + let t = geom.param_at(GradientKind::Linear, Point::new(x, 100.0)); + let got = g.gradient.sample(t as f32).0.to_rgb(); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.05), "run {run} at {u}: {got:?}"); + } + } +} + +/// A diamond centered in the shape is written in PDF as four linear gradients, each with the runs +/// from the center out to the shape's corner, not padded short of it. +#[test] +fn a_diamond_writes_each_quadrant_from_its_center_out() { + // From the center to a corner: 16 lengths of the vector, 10 runs each. + let c = Point::new(100.0, 100.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(10.0, 0.0), aspect: 1.0, focal: None }; + let d = doc_n(GradientKind::Diamond, GradientSpread::Repeat, geom, false, 10); + let report = export_with_report(&d, &PdfOptions::uncompressed()).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("at most 256 runs")), "{:?}", report.warnings); +} + +/// A repeating gradient whose run reaches past the coordinates PDF holds is drawn as an image +/// rather than left out, for a linear, a radial and a diamond gradient. +#[test] +fn a_run_too_long_for_pdf_is_an_image() { + let c = Point::new(100.0, 100.0); + for (what, kind) in [("linear", GradientKind::Linear), ("radial", GradientKind::Radial), ("diamond", GradientKind::Diamond)] { + // Three runs of the vector, each longer than PDF's coordinates reach. + let geom = GradientGeom { start: c, end: c + Vec2::new(1.2e10, 0.0), aspect: 1.0, focal: None }; + let text = uncompressed(&doc(kind, GradientSpread::Repeat, geom, false), &PdfOptions::uncompressed()); + assert!(image(&text), "{what}: drawn as an image"); + } +} + +/// A Perceptual gradient of a spot ink stays Separation shadings in that ink while its repeated +/// stops' ink curve fits, and is an image (rather than tints mixed the Classic way) once it no +/// longer does, for a linear and a diamond gradient. +#[test] +fn a_perceptual_spot_gradient_too_long_once_repeated_is_an_image() { + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + // Gold and paper white in turn: a curve of many tints in each run. + let stops: Vec = (0..=4) + .map(|i| match i % 2 { + 0 => { + let mut s = GradientStop::new(i as f32 / 4.0, gold); + (s.swatch, s.tint) = (Some("Gold".into()), 1.0); + s + } + _ => GradientStop::new(i as f32 / 4.0, Color::cmyk(0.0, 0.0, 0.0, 0.0)), + }) + .collect(); + let c = Point::new(100.0, 100.0); + for (kind, short, long) in [ + ( + GradientKind::Linear, + GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(20.4, 100.0), aspect: 1.0, focal: None }, + GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(180.0, 100.0), aspect: 1.0, focal: None }, + ), + ( + GradientKind::Diamond, + GradientGeom { start: c, end: c + Vec2::new(0.3, 0.0), aspect: 1.0, focal: None }, + GradientGeom { start: c, end: c + Vec2::new(80.0, 0.0), aspect: 1.0, focal: None }, + ), + ] { + for (geom, repeats, image_wanted) in [(long, 2, false), (short, 1, true)] { + let g = Gradient { + method: vectorcraft_color::GradientMethod::Perceptual, + spread: GradientSpread::Repeat, + repeats, + ..Gradient::new(kind, stops.clone()) + }; + let padded = g.padded_over(&geom, Rect::new(20.0, 20.0, 180.0, 180.0)).unwrap(); + assert!(g.fits_ink_tints() && padded.gradient.fits_ink_tints() != image_wanted, "{kind:?} {repeats}"); + let mut gp = GradientPaint::new(g); + gp.geom = Some(geom); + let mut d = Document::new(200.0, 200.0); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + d.swatches.push(vectorcraft_color::Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + let text = uncompressed(&d, &PdfOptions::uncompressed()); + assert_eq!(image(&text), image_wanted, "{kind:?} {repeats}"); + assert_eq!(text.contains("[/Separation/Gold"), !image_wanted, "{kind:?} {repeats}"); + } + } +} + +/// A gradient that repeats with no stops (a file can hold one) exports, filled and stroked. +#[test] +fn a_spread_gradient_without_stops_exports() { + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(kind, vec![]) }); + let paint = Paint::Gradient(Box::new(g)); + let mut d = Document::new(200.0, 200.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(paint.clone(), paint, 4.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + export(&d, &PdfOptions::default()).unwrap(); + } +} + +/// A run far too short for the shape is written with at most 256 runs, padding past them, with a +/// warning. +#[test] +fn far_repeats_are_capped_with_a_warning() { + let geom = GradientGeom { start: Point::new(20.0, 20.0), end: Point::new(20.5, 20.0), aspect: 1.0, focal: None }; + let report = export_with_report(&doc(GradientKind::Linear, GradientSpread::Repeat, geom, false), &PdfOptions::uncompressed()).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("at most 256 runs")), "{:?}", report.warnings); +} diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 0e149579f5..98cf893dee 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -616,6 +616,7 @@ fn decode_paint(v: &Value, at: &str) -> Result { } stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); g.angle = g.angle.rem_euclid(360.0); + g.gradient.repeats = g.gradient.repeat_count(); let conic = g.gradient.kind == vectorcraft_color::GradientKind::Conic; let no_focal = conic || g.gradient.kind == vectorcraft_color::GradientKind::Diamond; if let Some(geom) = &mut g.geom { diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs index dfd2f53927..0bcc48a17f 100644 --- a/crates/render/src/dither.rs +++ b/crates/render/src/dither.rs @@ -58,7 +58,7 @@ fn cells(px: f64) -> u16 { fn key(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: &[Range; 2], ink: Ink, fill: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); let gr = &g.gradient; - (gr.kind as u8, gr.dither && fill, gr.method as u8).hash(&mut h); + (gr.kind as u8, gr.dither && fill, gr.method as u8, gr.painted_spread()).hash(&mut h); for s in &gr.stops { (s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h); s.color.key().hash(&mut h); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index dd83e4cee7..1d39ece10c 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -19,7 +19,15 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink), Paint::Gradient(g) => { - let geom = g.resolve(bounds); + // A gradient that repeats or reflects: one run of its stops over its period, which + // the renderer extends past both ends. + let spread = g.gradient.is_spread(); + let geom = if spread { g.resolve(bounds).period(g.gradient.kind, g.gradient.repeat_count()) } else { g.resolve(bounds) }; + let extend = match g.gradient.spread { + _ if !spread => peniko::Extend::Pad, + vectorcraft_color::GradientSpread::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Repeat, + }; let adjusted = f.ink.gradient(&g.gradient); let gradient = adjusted.as_ref().unwrap_or(&g.gradient); let ink = if adjusted.is_some() { f.ink.base() } else { f.ink }; @@ -42,9 +50,11 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } GradientKind::Conic => { // One turn about the origin, carried onto the extent ellipse: its frame starts - // the sweep along the vector, and a mirrored frame reverses it. + // the sweep along the vector, and a mirrored frame reverses it. Repeated `n` + // times, one run takes 1/n of the turn. ctx.set_paint_transform(geom.conic_frame()); - peniko::Gradient::new_sweep((0.0, 0.0), 0.0, std::f32::consts::TAU).with_stops(stops.as_slice()) + let turn = if spread { std::f32::consts::TAU / g.gradient.repeat_count() as f32 } else { std::f32::consts::TAU }; + peniko::Gradient::new_sweep((0.0, 0.0), 0.0, turn).with_stops(stops.as_slice()) } // peniko has no diamond gradient: it is drawn as pixels, as a dithered fill is. GradientKind::Diamond => return crate::dither::set_dithered_paint(ctx, g, bounds, bounds, f.ink, false), @@ -57,7 +67,7 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr }; // Colour and opacity blend each on its own, as the model samples them and as the PDF // and SVG exports draw them (peniko's default premultiplies). - ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); + ctx.set_paint(grad.with_extend(extend).with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); true } Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f), diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 2bf0d165db..1d61a2656c 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -212,11 +212,12 @@ impl Renderer { let tol = 2f64.powi(level); // Reach a pixel past the stroke, so its antialiased edge has colour under it. let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); + let unrolled = g.unrolled(); Arc::new( slices .into_iter() .map(|s| { - let (b, paint) = (s.shape.bounding_box(), s.paint(g)); + let (b, paint) = (s.shape.bounding_box(), s.paint(&unrolled)); (s.shape, paint, b) }) .collect(), diff --git a/crates/render/src/tests/strokegradient.rs b/crates/render/src/tests/strokegradient.rs index 961f18590f..f286a04252 100644 --- a/crates/render/src/tests/strokegradient.rs +++ b/crates/render/src/tests/strokegradient.rs @@ -47,6 +47,18 @@ fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() } } +/// A gradient that reflects twice runs its stops twice along the stroke, the second run +/// backwards. +#[test] +fn along_a_reflecting_gradient_runs_its_stops_that_many_times() { + let g = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..Gradient::default() }; + let img = render(&line((20.0, 100.0), (180.0, 100.0)), g, StrokeGradientMode::Along, true); + for (f, want) in [(0.125, 0.75), (0.375, 0.25), (0.625, 0.25), (0.875, 0.75)] { + let x = 20.0 + 160.0 * f; + assert!((grey(&img, x, 100.0) - want).abs() < 0.03, "at {f}: {} vs {want}", grey(&img, x, 100.0)); + } +} + #[test] fn across_runs_from_the_left_edge_to_the_right() { // Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110). diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 9a9e059fba..66d61e9bbb 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -9,4 +9,5 @@ mod golden; mod gradient_alpha; mod gradient_text; mod radial_focal; +mod spread; mod stroke_geometry; diff --git a/crates/render/tests/it/spread.rs b/crates/render/tests/it/spread.rs new file mode 100644 index 0000000000..9e0c73d545 --- /dev/null +++ b/crates/render/tests/it/spread.rs @@ -0,0 +1,93 @@ +//! Gradients that repeat or reflect: the canvas draws each kind's stops `repeats` times along its +//! vector and on past its ends, as the model's parameter gives them, on fills and strokes. + +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, Paint}; +use vectorcraft_doc::{Appearance, Document}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, render_view}; + +/// A white-to-black gradient of `kind` on `geom` that spreads by `spread`, twice. +fn gradient(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, dither: bool) -> GradientPaint { + let mut gp = GradientPaint::new(Gradient { kind, spread, repeats: 2, dither, ..Gradient::default() }); + gp.geom = Some(geom); + gp +} + +/// A 100 pt square filled with `fill` and stroked `width` wide with `stroke`. +fn square(fill: Paint, stroke: Paint, width: f64) -> Document { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(fill, stroke, width), |_| {}); + b.build() +} + +/// Every 5th pixel inside `inside`, away from where Repeat jumps from black back to white and +/// from a conic's center, has the gray the model gives it (pixel centers), within what a pixel's +/// step on the ramp and dither's noise allow; at least `least` pixels are checked. +fn assert_follows_model(img: &Image, g: &GradientPaint, inside: Rect, least: usize, what: &str) { + let geom = g.geom.unwrap(); + let kind = g.gradient.kind; + let mut checked = 0; + for y in (2..100).step_by(5) { + for x in (2..100).step_by(5) { + let p = Point::new(x as f64 + 0.5, y as f64 + 0.5); + if !inside.contains(p) || (kind == GradientKind::Conic && p.distance(geom.start) < 12.0) { + continue; + } + let at = |q: Point| g.gradient.spread_param(geom.param_at(kind, q)); + let t = at(p); + if g.gradient.spread == GradientSpread::Repeat && !(0.06..0.94).contains(&t) { + continue; + } + // How far the gray moves across a pixel there: pixels drawn from a grid of cells (a + // diamond's, a dithered fill's) are sampled up to half a pixel away. + let step = [Vec2::new(0.5, 0.0), Vec2::new(0.0, 0.5), Vec2::new(-0.5, 0.0), Vec2::new(0.0, -0.5)] + .map(|d| 2.0 * (at(p + d) - t).abs()) + .into_iter() + .fold(0.0, f64::max); + let tolerance = 6 + (step * 255.0).ceil() as u8; + let want = ((1.0 - t.clamp(0.0, 1.0)) * 255.0).round() as u8; + let got = img.over_white(x, y)[0]; + assert!(got.abs_diff(want) <= tolerance, "{what} ({x},{y}) t={t:.3}: got {got}, want {want} (within {tolerance})"); + checked += 1; + } + } + assert!(checked >= least, "{what}: only {checked} pixels checked"); +} + +fn geoms() -> [(GradientKind, GradientGeom); 5] { + let c = Point::new(50.0, 50.0); + let level = GradientGeom { start: c, end: c + Vec2::new(36.0, 12.0), aspect: 0.7, focal: None }; + [ + (GradientKind::Linear, GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(70.0, 45.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, level), + (GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-10.0, 6.0)), ..level }), + (GradientKind::Conic, level), + (GradientKind::Diamond, level), + ] +} + +/// Linear, radial (centered and off-center), conic and diamond fills repeat and reflect as the +/// model says, inside the vector and past its ends; so does a dithered fill, drawn as pixels. +#[test] +fn fills_repeat_and_reflect_for_every_kind() { + for (kind, geom) in geoms() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for dither in [false, true] { + let g = gradient(kind, spread, geom, dither); + let img = render_view(&square(Paint::Gradient(Box::new(g.clone())), Paint::None, 0.0), 100, 100); + assert_follows_model(&img, &g, Rect::new(12.0, 12.0, 88.0, 88.0), 100, &format!("{kind:?} {spread:?} dither {dither}")); + } + } + } +} + +/// A stroke's gradient repeats out to its miters too. +#[test] +fn a_stroke_repeats_out_past_the_paint_box() { + for (kind, geom) in geoms() { + let g = gradient(kind, GradientSpread::Reflect, geom, false); + let img = render_view(&square(Paint::None, Paint::Gradient(Box::new(g.clone())), 12.0), 100, 100); + assert_follows_model(&img, &g, Rect::new(5.0, 5.0, 15.0, 95.0), 20, &format!("{kind:?} stroke")); + } +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index ada9a4375c..bdda3a65a9 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind}; use vectorcraft_effects::RasterFx; use vectorcraft_geom::shapes::{self, CornerKind}; @@ -559,9 +559,25 @@ impl Rules<'_> { /// writes (midpoints as stops of their own). `None` for freeform gradients. fn gradient(&mut self, g: &GradientPaint, b: Rect, opacity: f32) -> Option { let geom = g.resolve(b); + // A linear, radial or diamond gradient that repeats or reflects: a repeating gradient of + // one cycle (a run, or a run and its mirror) over that cycle's placement + // (https://www.w3.org/TR/css-images-3/#repeating-gradients). A conic one: its stops + // repeated around the turn. + let kind = g.gradient.kind; + let unrolled = g.gradient.unrolled(); + let (gradient, geom, repeating) = match g.gradient.cycle() { + Some((cycle, runs)) if kind != GradientKind::Conic => { + // CSS radials start at their center: the cycle is scaled about it. + let centered = GradientGeom { focal: None, ..geom }; + let cycle_geom = centered.period(kind, g.gradient.repeat_count()).levels_scaled(kind, f64::from(runs)); + let geom = GradientGeom { focal: geom.focal, ..cycle_geom }; + (std::borrow::Cow::Owned(cycle), geom, "repeating-") + } + _ => (unrolled, geom, ""), + }; let stops = |pos: &dyn Fn(f32) -> f64| { let stops: Vec = - g.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, 2))).collect(); stops.join(", ") }; match g.gradient.kind { @@ -578,7 +594,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!("linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) + Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) } // CSS has no diamond gradient: the ellipse through the square's corners stands in. GradientKind::Radial | GradientKind::Diamond => { @@ -594,7 +610,7 @@ impl Rules<'_> { format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) }; let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0)); - Some(format!("radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) + Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) } GradientKind::Conic => { // CSS sweeps clockwise from the `from` angle, 0deg straight up @@ -607,7 +623,7 @@ impl Rules<'_> { if (geom.aspect.abs() - 1.0).abs() > 1e-9 && geom.unit_frame().is_some() { self.warn("conic gradients are written circular (an elliptical sweep is left out)"); } - let mut stops: Vec<(f32, Color, f32)> = g.gradient.expanded().map(|(t, c, o, _)| (t, c, o)).collect(); + let mut stops: Vec<(f32, Color, f32)> = gradient.expanded().map(|(t, c, o, _)| (t, c, o)).collect(); if geom.is_mirrored() { stops.reverse(); stops.iter_mut().for_each(|s| s.0 = 1.0 - s.0); diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index e982ab315c..515e556a4b 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -273,7 +273,15 @@ impl Writer<'_> { // Object fills and strokes are four linear gradients or images (see `raster_paint`). self.warn("diamond gradients on characters are written as radial gradients"); } + // A gradient that repeats or reflects: one run of its stops over its period, and + // `spreadMethod` to carry it on past both ends. + let spread = g.gradient.is_spread(); let mut geom = g.resolve(bounds); + if spread { + // A conic is written as a linear gradient along its vector: its runs lie along it. + let written = if g.gradient.kind == GradientKind::Conic { GradientKind::Linear } else { g.gradient.kind }; + geom = geom.period(written, g.gradient.repeat_count()); + } geom.transform(self.xf, g.gradient.kind); if g.gradient.kind != GradientKind::Radial { // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can @@ -304,6 +312,11 @@ impl Writer<'_> { format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), ), }; + let attrs = match g.gradient.spread { + _ if !spread => attrs, + vectorcraft_color::GradientSpread::Reflect => format!("{attrs} spreadMethod=\"reflect\""), + _ => format!("{attrs} spreadMethod=\"repeat\""), + }; let stops: Vec = g .gradient .expanded() @@ -558,10 +571,10 @@ impl Writer<'_> { } } None => { - let sample = g.gradient.sampler(); - for (pts, t) in g.resolve(bounds).conic_wedges(cover, CONIC_WEDGES, true) { + let (sample, spread) = (g.gradient.sampler(), g.gradient.spreader()); + for (pts, t) in g.resolve(bounds).conic_wedges(cover, g.gradient.conic_wedge_count(CONIC_WEDGES), true) { let d = self.path_d(&PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)), self.xf); - self.line(&format!("", sample(t as f32).0.to_hex())); + self.line(&format!("", sample(spread(t) as f32).0.to_hex())); } } }, diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 47fcfbaf14..4ac3530b19 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -49,7 +49,6 @@ mod depth; mod files; mod fx; mod linear_rgb; -mod spread; mod symbol_units; mod text; mod vector_effect; diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index ca31f28b85..33878ac998 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -4,10 +4,11 @@ use super::prepass::link; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, Dash, FillLayer, FillRule, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, ImageBlob, ImageObject, Importer, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, PathData, PatternDef, Point, Rect, Reuse, - StrokeLayer, Vec2, fnv1a, fx, shapes, spread, text, vector_effect, + StrokeLayer, Vec2, fnv1a, fx, shapes, text, vector_effect, }; use crate::SvgError; use kurbo::BezPath; +use vectorcraft_color::GradientSpread; /// How many clip paths deep (``) an object's clip is read. const MAX_CLIP_NEST: usize = 16; @@ -246,10 +247,7 @@ impl Importer { // space; runs paint in text space. let to_text = Affine::scale(grow) * obj.xf.inverse(); let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) { - Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { - let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m)); - im.paint(f.paint(), m, Some(area)) - }), + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| im.paint(f.paint(), m)), _ => Paint::None, }; let resolved: Vec = (0..paints).map(|k| slot_paint(self, k, to_text)).collect(); @@ -608,9 +606,8 @@ impl Importer { Some(NodeKind::Compound { children, rule: r }) } - /// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or - /// repeated gradient has to reach). - fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option) -> Paint { + /// A usvg paint mapped by `m`. + fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { let (kind, base, mut geom) = match p { usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)), usvg::Paint::Pattern(pt) => return self.pattern(pt, m), @@ -637,28 +634,14 @@ impl Importer { } }; geom.transform(m * aff(base.transform()), kind); - let mut stops = self.stops(base); - let reflect = match base.spread_method() { - usvg::SpreadMethod::Pad => None, - usvg::SpreadMethod::Reflect => Some(true), - usvg::SpreadMethod::Repeat => Some(false), + let mut gradient = Gradient::new(kind, self.stops(base)); + // `spreadMethod` is the gradient's spread; its stops run once along the vector. + gradient.spread = match base.spread_method() { + usvg::SpreadMethod::Pad => GradientSpread::Pad, + usvg::SpreadMethod::Reflect => GradientSpread::Reflect, + usvg::SpreadMethod::Repeat => GradientSpread::Repeat, }; - if let Some(reflect) = reflect { - match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) { - Some(spread::Expanded::Whole) => {} - Some(spread::Expanded::Capped) => { - self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS)) - } - None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())), - } - } - Paint::Gradient(Box::new(GradientPaint { - gradient: Gradient::new(kind, stops), - geom: Some(geom), - angle: geom.angle_deg(), - swatch: None, - freeform: None, - })) + Paint::Gradient(Box::new(GradientPaint { gradient, geom: Some(geom), angle: geom.angle_deg(), swatch: None, freeform: None })) } /// `` → a pattern swatch painted with the pattern's tile placement. The swatch art is @@ -692,12 +675,12 @@ impl Importer { Paint::Pattern { pattern: name, xf } } - /// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`. - fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option) -> (Appearance, FillRule) { + /// The fill and stroke of `p` mapped by `m`. + fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) { let mut fill = None; let mut r = FillRule::NonZero; if let Some(f) = p.fill() { - let paint = self.paint(f.paint(), m, area); + let paint = self.paint(f.paint(), m); let mut fl = FillLayer::new(paint); fl.opacity = f.opacity().get(); r = rule(f.rule()); @@ -708,9 +691,7 @@ impl Importer { // A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a // stroke of that width in the document is exact. let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() }; - // The stroke reaches past the path (miters further: a generous margin). - let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); - let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); + let paint = self.paint(s.paint(), m); let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); sl.opacity = s.opacity().get(); sl.cap = match s.linecap() { @@ -749,7 +730,7 @@ impl Importer { if path.is_empty() { return None; } - let (appearance, r) = self.appearance(p, acc, path.bounds()); + let (appearance, r) = self.appearance(p, acc); // Shared, so a compound path's subpaths don't each copy a gradient's stops. let appearance: vectorcraft_doc::Shared = appearance.into(); let mut n = if path.subpaths.len() > 1 { diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs deleted file mode 100644 index 1c6c1f3ec4..0000000000 --- a/crates/svg/src/import/spread.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! `spreadMethod="reflect"` and `"repeat"`: gradients only pad past their ends, so the stops are -//! repeated (every other period mirrored, for reflect) as many times as the painted area needs, -//! and the gradient stretched over those periods. - -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; -use vectorcraft_geom::{Point, Rect}; - -/// Most periods a gradient is expanded to. -pub(super) const MAX_PERIODS: usize = 64; - -/// What [`expand`] did. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Expanded { - /// Every period the area shows. - Whole, - /// [`MAX_PERIODS`] of them: the gradient pads past those. - Capped, -} - -/// Expand `stops` and `geom` (document space) so the gradient paints `area` as the spread method -/// does: `reflect` mirrors every other period, else they repeat. -pub(super) fn expand(kind: GradientKind, geom: &mut GradientGeom, stops: &mut Vec, reflect: bool, area: Rect) -> Expanded { - let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; - let ts = corners.map(|p| geom.param_at(kind, p)); - if ts.iter().any(|t| !t.is_finite()) || stops.is_empty() { - return Expanded::Whole; - } - let radial = kind == GradientKind::Radial; - // The parameter's range over the area: a corner holds its extremes (radial levels are nested - // convex rings from the focal point, where the parameter is 0). - let lo = if radial { 0.0 } 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); - if lo >= 0.0 && hi <= 1.0 { - return Expanded::Whole; - } - let capped = hi - lo > MAX_PERIODS as f64; - // Capped, the periods kept start no further back than half of them. - let lo = if capped { lo.max(-(MAX_PERIODS as f64) / 2.0) } else { lo }; - let n = (hi - lo).min(MAX_PERIODS as f64); - let period = whole_period(stops); - let mut out = Vec::with_capacity(period.len() * n as usize); - for j in 0..n as usize { - let mirrored = reflect && (lo as i64 + j as i64).rem_euclid(2) == 1; - let one = if mirrored { mirror(&period) } else { period.clone() }; - out.extend(one.into_iter().map(|s| GradientStop { offset: ((j as f64 + s.offset as f64) / n) as f32, ..s })); - } - *stops = out; - let u = geom.end - geom.start; - if radial { - // Level t of an off-centre radial is the extent ellipse scaled by t about the line from the - // focal point to the centre; level n·t' of the new one: the centre moves away from the - // focal point n times as far, and the radius grows n times. - let Some(frame) = geom.unit_frame() else { return Expanded::Whole }; - let f = geom.focal.map_or(Point::ORIGIN, |f| frame.inverse() * f); - geom.start = frame * (f.to_vec2() * (1.0 - n)).to_point(); - geom.end = geom.start + u * n; - } else { - let start = geom.start; - geom.start = start + u * lo; - geom.end = start + u * (lo + n); - } - if capped { Expanded::Capped } else { Expanded::Whole } -} - -/// The stops of one period, from offset 0 to 1: the end colours held out to the ends. -fn whole_period(stops: &[GradientStop]) -> Vec { - let mut out = stops.to_vec(); - if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { - out.insert(0, GradientStop { offset: 0.0, ..first }); - } - if let Some(last) = out.last_mut() { - // The last stop's midpoint leads nowhere; the copy at 1 takes over. - if last.offset < 1.0 { - let end = GradientStop { offset: 1.0, ..last.clone() }; - out.push(end); - } - } - out -} - -/// A period mirrored: the stops in reverse, each midpoint measured from the other side. -fn mirror(period: &[GradientStop]) -> Vec { - let mut g = Gradient::new(GradientKind::Linear, period.to_vec()); - g.reverse(); - g.stops -} - -#[cfg(test)] -mod tests { - use super::*; - use vectorcraft_color::Color; - - fn stops() -> Vec { - let mut a = GradientStop::new(0.0, Color::BLACK); - a.midpoint = 0.3; - vec![a, GradientStop::new(1.0, Color::WHITE)] - } - - #[test] - fn a_linear_gradient_repeats_over_the_area() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - let r = expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(-5.0, 0.0, 25.0, 10.0)); - assert_eq!(r, Expanded::Whole); - // From -10 to 30: four periods. - assert_eq!((geom.start, geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); - let offsets: Vec = s.iter().map(|s| s.offset).collect(); - assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); - assert!(s.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); - } - - #[test] - fn reflect_mirrors_every_other_period_and_its_midpoints() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - expand(GradientKind::Linear, &mut geom, &mut s, true, Rect::new(0.0, 0.0, 20.0, 10.0)); - let colours: Vec = s.iter().map(|s| s.color).collect(); - assert_eq!(colours, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); - // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. - assert!((s[2].midpoint - 0.7).abs() < 1e-6, "{s:?}"); - } - - #[test] - fn stops_inside_the_period_hold_their_colours_to_its_ends() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; - expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 20.0, 10.0)); - let offsets: Vec = s.iter().map(|s| s.offset).collect(); - assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); - } - - #[test] - fn a_radial_gradient_grows_about_its_focal_point() { - let c = Point::new(50.0, 50.0); - let mut geom = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; - let before = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); - let mut s = stops(); - expand(GradientKind::Radial, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 100.0)); - let n = s.len() / 2; - assert!(n >= 5, "{n}"); - assert_eq!(geom.focal, Some(Point::new(55.0, 50.0))); - // Every point keeps its level: the new parameter is the old one over n. - let after = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); - assert!((after * n as f64 - before).abs() < 1e-9, "{before} {after}"); - } - - #[test] - fn far_repeats_are_capped() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - assert_eq!(expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 1.0)), Expanded::Capped); - assert_eq!(s.len(), 2 * MAX_PERIODS); - } -} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8a447f6c38..066392bb87 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -74,8 +74,8 @@ //! * A `` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back //! as a hidden layer; other undisplayed objects as hidden objects, unless a `` refers to //! them. -//! * `spreadMethod` reflect and repeat are expanded into stops over the painted area (at most -//! 64 periods). +//! * `spreadMethod` reflect and repeat become the gradient's spread, Reflect or Repeat, its stops +//! running once along the vector. //! * `` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG //! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. //! * Text lines after the first start at the first line's x; absolute positions inside type on a diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 0c1af2d287..2ca6dcc30b 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -335,6 +335,45 @@ fn diamond_gradients_give_css_radial_gradients_through_their_corners() { ); } +/// A linear or radial gradient that repeats or reflects is a repeating CSS gradient of one cycle (a +/// run, or for Reflect a run and its mirror); a conic one has its stops repeated around the turn. +#[test] +fn spread_gradients_give_repeating_css_gradients() { + use vectorcraft_color::GradientSpread; + let mut d = doc(); + let mut css = |kind, spread, geom: GradientGeom| { + let mut g = GradientPaint::new(Gradient { kind, spread, repeats: 4, ..Gradient::default() }); + g.geom = Some(geom); + let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0); + n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into(); + let id = add(&mut d, n); + prop(&one(&d, id, &CssOptions::default()), "background-image").map(str::to_string) + }; + // Left to right across the box: a run is a quarter of it. + let across = GradientGeom { start: Point::new(0.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, across).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%)") + ); + assert_eq!( + css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)") + ); + 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(), + Some("repeating-radial-gradient(circle 20px at 40px 30px, #ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%)") + ); + // A diamond's stand-in, the ellipse through its corners, repeats with each run of squares. + assert_eq!( + css(GradientKind::Diamond, GradientSpread::Repeat, ring).as_deref(), + Some("repeating-radial-gradient(circle 10px at 40px 30px, #ffffff 0%, #000000 100%)") + ); + let conic = css(GradientKind::Conic, GradientSpread::Repeat, ring).unwrap(); + assert!(conic.starts_with("conic-gradient(from 90deg at 40px 30px, #ffffff 0%, #000000 25%, #ffffff 25%"), "{conic}"); + assert!(conic.ends_with("#000000 100%)"), "{conic}"); +} + #[test] fn shadows_and_unsupported_art() { let mut d = doc(); diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index ac0b2442ec..b241f2e75b 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -32,6 +32,7 @@ mod options; mod prop_svg; mod raster_paints; mod roundtrip; +mod spread; mod stroke_export; mod svg_glyphs; mod symbols_layers; diff --git a/crates/svg/tests/it/spread.rs b/crates/svg/tests/it/spread.rs new file mode 100644 index 0000000000..9a7cc0739d --- /dev/null +++ b/crates/svg/tests/it/spread.rs @@ -0,0 +1,132 @@ +//! Gradients that repeat or reflect: SVG export writes `spreadMethod` over one run of the stops +//! (a conic's wedges and a diamond's quarters repeat with them), and an independent renderer +//! draws the file as the canvas draws the gradient; SVG import reads `spreadMethod` as the +//! gradient's spread; CSS writes repeating gradients. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_svg::{ExportOptions, export_full, import}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +/// The SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +/// A red-to-blue gradient of `kind` that spreads by `spread` 3 times, placed at `geom`. +fn paint(kind: GradientKind, spread: GradientSpread, geom: GradientGeom) -> Paint { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(kind, stops) }); + g.geom = Some(geom); + Paint::Gradient(Box::new(g)) +} + +/// A 200 × 160 document holding a rectangle painted with `fill` and `stroke`. +fn doc(fill: Paint, stroke: Paint, width: f64) -> Document { + let mut d = Document::new(200.0, 160.0); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0)), Appearance::basic(fill, stroke, width)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> { + let c = Point::new(100.0, 80.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(50.0, -20.0), aspect: 0.7, focal: None }; + vec![ + ("linear", GradientKind::Linear, GradientGeom { start: Point::new(60.0, 60.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }), + ("radial", GradientKind::Radial, geom), + ("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-12.0, 10.0)), ..geom }), + ("conic", GradientKind::Conic, geom), + ("diamond", GradientKind::Diamond, geom), + ] +} + +/// Each kind's fill and stroke, repeating and reflecting, exported and drawn by resvg, looks as the +/// canvas paints it, inside the vector and past its ends. +#[test] +fn an_independent_renderer_draws_the_export_as_the_canvas_does() { + for (what, kind, geom) in cases() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for stroke in [false, true] { + let d = if stroke { doc(Paint::None, paint(kind, spread, geom), 14.0) } else { doc(paint(kind, spread, geom), Paint::None, 0.0) }; + let o = export_full(&d, &ExportOptions::default(), None); + if matches!(kind, GradientKind::Linear | GradientKind::Radial | GradientKind::Diamond) { + let attr = if spread == GradientSpread::Repeat { "spreadMethod=\"repeat\"" } else { "spreadMethod=\"reflect\"" }; + assert!(o.svg.contains(attr), "{what} {spread:?}: {}", o.svg); + } + // Hard edges (where Repeat starts the stops again, and between a conic's wedges) + // differ by a pixel's antialiasing at most. + assert_similar(&render_artboard(&d), &resvg_render(&o.svg, 200, 160), 10.0, 0.03); + } + } + } +} + +/// One run lies along the vector: a linear gradient's `x2` is a third of the way along it. +#[test] +fn the_written_vector_holds_one_run() { + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None }; + let o = export_full(&doc(paint(GradientKind::Linear, GradientSpread::Repeat, geom), Paint::None, 0.0), &ExportOptions::default(), None); + assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg); +} + +/// On type characters, where SVG writes a conic as a linear gradient along its vector, the runs +/// lie along that vector: a third of it each. +#[test] +fn a_conic_on_characters_repeats_along_its_vector() { + use vectorcraft_doc::{CharStyle, TextObject}; + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None }; + let Paint::Gradient(g) = paint(GradientKind::Conic, GradientSpread::Repeat, geom) else { panic!("a gradient") }; + let st = CharStyle { fill: Paint::Gradient(g), size: 48.0, ..CharStyle::default() }; + let mut d = Document::new(200.0, 160.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 80.0), "Fan", st)))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let o = export_full(&d, &ExportOptions::default(), None); + assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg); +} + +/// `spreadMethod` reads as the gradient's spread, with its stops and vector as written. +#[test] +fn import_reads_spread_method_as_the_spread() { + for (attr, want) in [("repeat", GradientSpread::Repeat), ("reflect", GradientSpread::Reflect), ("pad", GradientSpread::Pad)] { + let svg = format!( + r##""## + ); + let d = import(&svg).unwrap(); + let mut found = None; + d.walk(|n| { + if let (NodeKind::Path { .. }, Paint::Gradient(g)) = (&n.kind, n.appearance.fill_paint()) { + found = Some(g.clone()); + } + }); + let g = found.unwrap_or_else(|| panic!("{attr}: no gradient")); + assert_eq!((g.gradient.spread, g.gradient.repeat_count(), g.gradient.stops.len()), (want, 1, 2), "{attr}"); + let geom = g.geom.unwrap(); + assert_eq!((geom.start, geom.end), (Point::new(40.0, 0.0), Point::new(70.0, 0.0)), "{attr}"); + } +} + +/// Exported and read back, a repeating gradient keeps its spread; its count becomes part of the +/// vector, so it looks the same. +#[test] +fn a_spread_survives_export_and_import() { + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }; + let d = doc(paint(GradientKind::Linear, GradientSpread::Reflect, geom), Paint::None, 0.0); + let o = export_full(&d, &ExportOptions::default(), None); + let back = import(&o.svg).unwrap(); + let mut spreads = vec![]; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + spreads.push(g.gradient.spread); + } + }); + assert_eq!(spreads, [GradientSpread::Reflect]); + assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.01); +} diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 49a5a9a3c1..095e0d21cf 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -78,6 +78,14 @@ pub fn gradient_params(g: &GradientPaint) -> Value { if !g.gradient.method.is_classic() { v["method"] = json!(g.gradient.method.label().to_lowercase()); } + // A freeform gradient has no ends to spread past (`paint.setFill` refuses a spread for one). + if g.gradient.is_spread() { + v["spread"] = json!(g.gradient.spread.label().to_lowercase()); + } + // A gradient that pads keeps its count for later. + if g.gradient.repeat_count() != 1 { + v["repeats"] = json!(g.gradient.repeat_count()); + } v } diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index db3aad793e..c6fff7706f 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -47,7 +47,7 @@ pub fn paint_color_at(paint: &Paint, bounds: Option, p: Paint::Solid { color, .. } => Some(*color), Paint::Gradient(g) => { let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?; - let t = geom.param_at(g.gradient.kind, p); + let t = g.gradient.spread_param(geom.param_at(g.gradient.kind, p)); Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0) } _ => None, diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 6c3c76862c..9c8881b580 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -68,6 +68,8 @@ const CENTRE: f64 = 4.5; const CENTRE_HIT: f64 = 8.0; /// A stop dragged farther than this from the bar is deleted on release. const OFF_BAR: f64 = 24.0; +/// Half the length of the ticks across the bar where the runs of a repeating gradient meet. +const TICK: f64 = 5.0; /// A press becomes a drag after this much movement. const DRAG_START: f64 = 3.0; /// Arrow-key nudges of the selected stop (Shift: the big step). @@ -287,6 +289,7 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { if len < 1e-12 { return o; } + o.extend(run_ticks(cx, a)); let r = cx.tol(4.0); let (along, across) = (a.vector() / len * r, a.normal() * r); for i in 0..a.gradient.stops.len().saturating_sub(1) { @@ -302,6 +305,31 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { o } +/// Where each run of the stops of a gradient that repeats or reflects meets the next: a tick across +/// the bar at each such place along the vector, or, for a conic, a tick across its extent ellipse +/// at each such turn. None for a gradient that pads. +fn run_ticks(cx: &ToolContext, a: &Annotator) -> Vec { + if !a.gradient.is_spread() { + return vec![]; + } + let n = a.gradient.repeat_count(); + let conic = a.gradient.kind == GradientKind::Conic; + let frame = a.geom.conic_frame(); + let tick = a.normal() * cx.tol(TICK); + (1..n) + .flat_map(|k| { + let t = f64::from(k) / f64::from(n); + let (p, q) = if conic { + let (sin, cos) = (t * std::f64::consts::TAU).sin_cos(); + (frame * Point::new(0.9 * cos, 0.9 * sin), frame * Point::new(1.1 * cos, 1.1 * sin)) + } else { + (a.at(t) - tick, a.at(t) + tick) + }; + [Overlay::Line { a: p, b: q, color: LIGHT, dashed: false }, Overlay::Line { a: p, b: q, color: BAR, dashed: true }] + }) + .collect() +} + /// The vector a click applies to `id`: its current gradient's, else the default fit on its box. fn click_vector(cx: &ToolContext, id: NodeId) -> Option { let stroke = !cx.fill_active; diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 943ed3af7b..bdfa981c34 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -453,3 +453,36 @@ fn a_radial_with_a_negative_aspect_drags_to_a_positive_one() { let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 80.0)))).clone(); assert!((v["aspect"].as_f64().unwrap() - 70.0).abs() < 1e-9, "{v}"); } + +/// A gradient that repeats `n` times shows a tick across the bar where each run meets the next +/// (`n` - 1 of them), and a conic one across its extent at each such turn; one that pads shows +/// none. +#[test] +fn the_annotator_marks_where_the_runs_meet() { + use vectorcraft_color::GradientSpread; + let ticks = |kind: GradientKind, spread: GradientSpread| { + let (mut d, s) = graded(two()); + let n = d.node_mut(s.objects[0]).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!("a gradient fill") }; + (g.gradient.kind, g.gradient.spread, g.gradient.repeats) = (kind, spread, 4); + let geom = g.geom.unwrap(); + n.appearance.set_fill(Paint::Gradient(g)); + let p = paint(); + let o = GradientTool::default().overlays(&cx(&d, &s, &p)); + let lines: Vec<(Point, Point)> = + o.iter().filter_map(|o| if let Overlay::Line { a, b, dashed: true, .. } = o { Some((*a, *b)) } else { None }).collect(); + (lines, geom) + }; + let (lines, geom) = ticks(GradientKind::Linear, GradientSpread::Repeat); + assert_eq!(lines.len(), 3, "{lines:?}"); + for (k, (a, b)) in lines.iter().enumerate() { + let at = geom.start + (geom.end - geom.start) * ((k + 1) as f64 / 4.0); + assert!(a.midpoint(*b).distance(at) < 1e-9, "tick {k} at {at:?}: {a:?} {b:?}"); + } + let (lines, geom) = ticks(GradientKind::Conic, GradientSpread::Reflect); + assert_eq!(lines.len(), 3); + let frame = geom.conic_frame(); + let quarter = frame * Point::new(0.0, 1.0); + assert!(lines[0].0.midpoint(lines[0].1).distance(quarter) < 1e-9, "{lines:?} {quarter:?}"); + assert!(ticks(GradientKind::Radial, GradientSpread::Pad).0.is_empty()); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e666fcc8c6..7963b1ae2c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1924,6 +1924,8 @@ font-technology Variable Променливи Metallic Gradients Метални разградки Meters Метри Method: Метод: + Pad Удължаване + Repeats Повторения Millimeters Милиметри Minify Минимизиране на код Minimal Минимално diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2381deb135..5cfd7d9e9e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1834,6 +1834,8 @@ digits Malayalam Malajalam Save Image Trace Preset Sačuvaj unaprijed postavljeno praćenje slike Delete Image Trace Preset Izbriši unaprijed postavljeno praćenje slike Method: Metoda: + Pad Produži + Repeats Broj ponavljanja Mode: Način: Noise Šum Create: Kreiraj: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..ac69dcc544 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2531,6 +2531,8 @@ digits Malayalam Malaiàlam Meters Metres Method Mètode Method: Mètode: + Pad Allarga + Repeats Repeticions Mezzotint Mezzotinta Mezzotint… Mezzotinta… Millimeters Mil·límetres @@ -5094,6 +5096,9 @@ digits Malayalam Malaiàlam @msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) @msg `method` must be classic or perceptual `method` ha de ser classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` ha de ser pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` ha de ser un nombre enter d’1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’aplica als degradats lineals, radials, cònics i de rombe @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` han de ser nombres enters @msg `mode` applies to freeform gradients `mode` s’aplica als degradats de forma lliure @msg a negative `aspect` applies to conic gradients un `aspect` negatiu s’aplica als degradats cònics diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..28b7087088 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1861,6 +1861,8 @@ digits Malayalam Malajálamské Save Image Trace Preset Uložit přednastavení vektorizace obrazu Delete Image Trace Preset Odstranit přednastavení vektorizace obrazu Method: Metoda: + Pad Prodloužit + Repeats Počet opakování Mode: Režim: Noise Šum Create: Vytvořit: @@ -6082,6 +6084,9 @@ axis Y Axis osa Y @msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill) @msg `dither` must be true or false `dither` musí být true nebo false @msg `method` must be classic or perceptual `method` musí být classic nebo perceptual +@msg `spread` must be pad, repeat or reflect `spread` musí být pad, repeat nebo reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musí být celé číslo od 1 do {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` platí pro lineární, kruhové, kuželové a kosočtverečné přechody # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Nainstalovat „{name}“, který žádá o velký výpočetní rozpočet? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Každé spuštění může trvat až {minutes} minut a vykonat {times}krát víc práce, než smějí jiné zásuvné moduly. Běží na pozadí a můžete ho zrušit. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f48e9d6ea3..e07fbe2335 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1692,6 +1692,8 @@ digits Malayalam Malayalam Save Image Trace Preset Gem forudindstilling til billedsporing Delete Image Trace Preset Slet forudindstilling til billedsporing Method: Metode: + Pad Forlæng + Repeats Gentagelser Mode: Tilstand: Noise Støj Create: Opret: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..fb41b018b4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2000,6 +2000,8 @@ file-filter Templates Vorlagen Metallic Gradients Metallic-Verläufe Meters Meter Method: Methode: + Pad Fortsetzen + Repeats Wiederholungen Millimeters Millimeter Minify Minifizieren Minimal Minimal @@ -5673,6 +5675,9 @@ axis Y Axis Y-Achse @msg `dither` must be true or false `dither` muss true oder false sein Perceptual Perzeptiv @msg `method` must be classic or perceptual `method` muss classic oder perceptual sein +@msg `spread` must be pad, repeat or reflect `spread` muss pad, repeat oder reflect sein +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` muss eine ganze Zahl von 1 bis {MAX_REPEATS} sein +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gilt für lineare, radiale, konische Verläufe und Rautenverläufe # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? „{name}“ installieren, das ein großes Rechenbudget verlangt? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jede Ausführung kann bis zu {minutes} Minuten dauern und {times}-mal so viel Arbeit leisten, wie andere Plug-ins dürfen. Sie läuft im Hintergrund, und Sie können sie abbrechen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..43604f6626 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1765,6 +1765,8 @@ font-technology Variable Μεταβλητές Message: Μήνυμα: Metadata: Μεταδεδομένα: Method: Μέθοδος: + Pad Συνέχιση + Repeats Επαναλήψεις Millimeters Χιλιοστά Minimize Ελαχιστοποίηση Mode: Λειτουργία: @@ -6033,6 +6035,9 @@ axis Y Axis Άξονας Y @msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill) @msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false @msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual +@msg `spread` must be pad, repeat or reflect Το `spread` πρέπει να είναι pad, repeat ή reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} Το `repeats` πρέπει να είναι ακέραιος από 1 έως {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients Το `spread` ισχύει για γραμμικά, ακτινικά, κωνικά και ρομβοειδή ντεγκραντέ # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Να εγκατασταθεί το «{name}», που ζητά μεγάλο υπολογιστικό προϋπολογισμό; Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Κάθε εκτέλεση μπορεί να διαρκέσει έως {minutes} λεπτά και να κάνει {times} φορές τη δουλειά που επιτρέπεται στα άλλα πρόσθετα. Εκτελείται στο παρασκήνιο και μπορείτε να την ακυρώσετε. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..3322d57d30 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1994,6 +1994,8 @@ font-technology Variable Variables Metallic Gradients Degradados metálicos Meters Metros Method: Método: + Pad Prolongar + Repeats Repeticiones Millimeters Milímetros Minify Minificar Minimal Mínimo @@ -6024,6 +6026,9 @@ axis Y Axis Eje Y @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill) @msg `dither` must be true or false `dither` debe ser true o false @msg `method` must be classic or perceptual `method` debe ser classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` debe ser pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` debe ser un número entero de 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a los degradados lineales, radiales, cónicos y de rombo # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? ¿Instalar “{name}”, que pide un presupuesto de cálculo grande? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada ejecución puede tardar hasta {minutes} minutos y hacer {times} veces el trabajo que se permite a otros plugins. Se ejecuta en segundo plano y se puede cancelar. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..a67f087f4c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1834,6 +1834,8 @@ digits Malayalam Malajalam Save Image Trace Preset Tallenna kuvan jäljityksen esiasetus Delete Image Trace Preset Poista kuvan jäljityksen esiasetus Method: Menetelmä: + Pad Jatka + Repeats Toistokerrat Mode: Tila: Noise Kohina Create: Luo: @@ -6112,6 +6114,9 @@ axis Y Axis Y-akseli @msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill) @msg `dither` must be true or false `dither` on oltava true tai false @msg `method` must be classic or perceptual `method` on oltava classic tai perceptual +@msg `spread` must be pad, repeat or reflect `spread` on oltava pad, repeat tai reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` on oltava kokonaisluku väliltä 1–{MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` koskee lineaarisia, säteittäisiä ja kartiomaisia liukuvärejä sekä vinoneliöliukuvärejä # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Asennetaanko ”{name}”, joka pyytää suurta laskentabudjettia? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jokainen ajo voi kestää enintään {minutes} minuuttia ja tehdä {times} kertaa niin paljon työtä kuin muut laajennukset saavat. Se toimii taustalla, ja voit peruuttaa sen. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..978c980d29 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1988,6 +1988,8 @@ font-technology Variable Variables Metallic Gradients Dégradés métalliques Meters Mètres Method: Méthode : + Pad Prolonger + Repeats Répétitions Millimeters Millimètres Minify Minifier Minimal Minimal @@ -6077,6 +6079,9 @@ axis Y Axis Axe Y @msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill) @msg `dither` must be true or false `dither` doit valoir true ou false @msg `method` must be classic or perceptual `method` doit être classic ou perceptual +@msg `spread` must be pad, repeat or reflect `spread` doit être pad, repeat ou reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` doit être un nombre entier de 1 à {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’applique aux dégradés linéaires, radiaux, coniques et en losange # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installer « {name} », qui demande un budget de calcul important ? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Chaque exécution peut durer jusqu’à {minutes} minutes et faire {times} fois le travail permis aux autres modules externes. Elle s’exécute en arrière-plan, et vous pouvez l’annuler. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..1a569f37ba 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2762,6 +2762,8 @@ antiAlias Sharp शार्प Meters मीटर Method विधि Method: विधि: + Pad विस्तार करें + Repeats दोहराव Mezzotint मेज़ोटिंट Mezzotint… मेज़ोटिंट… Millimeters मिलीमीटर @@ -5873,6 +5875,9 @@ antiAlias Sharp शार्प @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) @msg `line` must be true or false `line` को true या false होना चाहिए @msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए +@msg `spread` must be pad, repeat or reflect `spread` pad, repeat या reflect होना चाहिए +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 से {MAX_REPEATS} तक की पूर्ण संख्या होनी चाहिए +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` रैखिक, रेडियल, शंक्वाकार और डायमंड ग्रेडिएंट्स पर लागू होता है @msg `min` is greater than `max` `min` अधिकतम `max` से अधिक है @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` और `maxAnchors` पूरे नंबर होना चाहिए @msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) `maxAngle` ({max_angle}°) से अधिक नहीं हो सकता है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..c783274c23 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2510,6 +2510,8 @@ file-filter Templates Sablonok Meters Méter Method Módszer Method: Módszer: + Pad Folytatás + Repeats Ismétlések Mezzotint Mezzotinto Mezzotint… Mezzotinto… Millimeters Milliméter @@ -6065,6 +6067,9 @@ axis Y Axis Y tengely @msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill) @msg `dither` must be true or false `dither`: true vagy false kell @msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet +@msg `spread` must be pad, repeat or reflect a `spread` értéke pad, repeat vagy reflect lehet +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} a `repeats` értéke 1 és {MAX_REPEATS} közötti egész szám lehet +@msg `spread` applies to linear, radial, conic and diamond gradients a `spread` a lineáris, sugaras, kúpos és rombusz színátmenetekre vonatkozik # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Telepíted a(z) „{name}” bővítményt, amely nagy számítási keretet kér? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Egy futás akár {minutes} percig tarthat, és {times}-szor annyi munkát végezhet, mint amennyit más bővítmények. A háttérben fut, és megszakíthatod. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 87c3f55cd5..c483064592 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1692,6 +1692,8 @@ digits Malayalam Malayalam Save Image Trace Preset Simpan preset telusur gambar Delete Image Trace Preset Hapus preset telusur gambar Method: Metode: + Pad Perpanjang + Repeats Jumlah ulangan Mode: Mode: Noise Derau Create: Buat: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..7a01de1bdf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2032,6 +2032,8 @@ file-filter Templates Modelli Metallic Gradients Sfumature metalliche Meters Metri Method: Metodo: + Pad Prolunga + Repeats Ripetizioni Millimeters Millimetri Minify Minifica Minimal Minimale @@ -5707,6 +5709,9 @@ axis Y Axis Asse Y @msg `dither` must be true or false `dither` deve essere true o false Perceptual Percettivo @msg `method` must be classic or perceptual `method` deve essere classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` deve essere pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve essere un numero intero da 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` si applica alle sfumature lineari, radiali, coniche e a rombo # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installare “{name}”, che chiede un budget di calcolo elevato? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Ogni esecuzione può durare fino a {minutes} minuti e svolgere {times} volte il lavoro consentito agli altri plug-in. Viene eseguita in background ed è possibile annullarla. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..8d2a51f8c5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2268,6 +2268,8 @@ antiAlias Sharp シャープ Metadata: メタデータ: Meters メートル Method: 方法: + Pad パッド + Repeats リピート回数 Millimeters ミリメートル Minify 最小化 Minimize 最小化 @@ -5772,6 +5774,9 @@ axis Y Axis Y 軸 @msg `dither` must be true or false `dither` には true または false を指定してください Perceptual 知覚的 @msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください +@msg `spread` must be pad, repeat or reflect `spread` には pad、repeat または reflect を指定してください +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` には 1 から {MAX_REPEATS} までの整数を指定してください +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` は線形、円形、円錐およびひし形グラデーションに適用されます # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 大きな計算予算を求める「{name}」をインストールしますか? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 1 回の実行に最大 {minutes} 分かかり、ほかのプラグインに許される {times} 倍の処理を行うことがあります。バックグラウンドで実行され、キャンセルできます。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..db87e7fe61 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1627,6 +1627,8 @@ digits Malayalam მალაიალამური Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: + Pad გაგრძელება + Repeats გამეორებების რაოდენობა Mode: რეჟიმი: Noise ხმაური Create: შექმნა: @@ -6033,6 +6035,9 @@ axis Y Axis Y ღერძი @msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill) @msg `dither` must be true or false `dither` უნდა იყოს true ან false @msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual +@msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? დავაყენოთ „{name}“, რომელიც დიდ გამოთვლით ბიუჯეტს ითხოვს? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. თითოეულ გაშვებას შეიძლება {minutes} წუთამდე დასჭირდეს და სხვა პლაგინებისთვის დაშვებულზე {times}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..e14b18ecd6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2258,6 +2258,8 @@ antiAlias Sharp 날카롭게 Metadata: 메타데이터: Meters 미터 Method: 방법: + Pad 연장 + Repeats 반복 횟수 Millimeters 밀리미터 Minify 압축 Minimize 최소화 @@ -5668,6 +5670,9 @@ axis Y Axis Y축 @msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다 Perceptual 지각적 @msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다 +@msg `spread` must be pad, repeat or reflect `spread`는 pad, repeat 또는 reflect여야 합니다 +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats`는 1에서 {MAX_REPEATS} 사이의 정수여야 합니다 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread`는 선형, 방사형, 원뿔형 및 다이아몬드 그레이디언트에 적용됩니다 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 큰 계산 예산을 요청하는 “{name}”을(를) 설치하시겠습니까? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 한 번 실행에 최대 {minutes}분이 걸리고 다른 플러그인에 허용되는 작업량의 {times}배를 수행할 수 있습니다. 백그라운드에서 실행되며 취소할 수 있습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4f9d484155..41b14298fb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1706,6 +1706,8 @@ digits Malayalam Malayalam Save Image Trace Preset Voorinstelling voor afbeeldingtracering opslaan Delete Image Trace Preset Voorinstelling voor afbeeldingtracering verwijderen Method: Methode: + Pad Doortrekken + Repeats Herhalingen Mode: Modus: Noise Ruis Create: Maken: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..c86689a895 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1779,6 +1779,8 @@ digits Malayalam Malajalam Save Image Trace Preset Zapisz ustawienie obrysu obrazu Delete Image Trace Preset Usuń ustawienie obrysu obrazu Method: Metoda: + Pad Przedłuż + Repeats Liczba powtórzeń Mode: Tryb: Noise Szum Create: Utwórz: @@ -5936,6 +5938,9 @@ axis Y Axis Oś Y @msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill) @msg `dither` must be true or false `dither` musi mieć wartość true lub false @msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual +@msg `spread` must be pad, repeat or reflect `spread` musi mieć wartość pad, repeat lub reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musi być liczbą całkowitą od 1 do {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` dotyczy gradientów liniowych, radialnych, stożkowych i rombowych # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Zainstalować „{name}”, wtyczkę, która prosi o duży budżet obliczeń? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Każde uruchomienie może trwać do {minutes} minut i wykonać {times} razy więcej pracy, niż mogą inne wtyczki. Działa w tle i można je anulować. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..81eac6497f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1701,6 +1701,8 @@ antiAlias Sharp Nítido Metallic Gradients Gradientes Metálicos Meters Metros Method: Método: + Pad Prolongar + Repeats Repetições Millimeters Milímetros Minify Minificar Minimize Minimizar @@ -5997,6 +5999,9 @@ axis Y Axis Eixo Y @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill) @msg `dither` must be true or false `dither` deve ser true ou false @msg `method` must be classic or perceptual `method` deve ser classic ou perceptual +@msg `spread` must be pad, repeat or reflect `spread` deve ser pad, repeat ou reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve ser um número inteiro de 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a gradientes lineares, radiais, cônicos e em losango # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Instalar “{name}”, que pede um orçamento de processamento grande? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada execução pode levar até {minutes} minutos e fazer {times} vezes o trabalho permitido a outros plug-ins. Ela é executada em segundo plano, e você pode cancelá-la. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..dbbe7722b5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2021,6 +2021,8 @@ file-filter Templates Шаблоны Metallic Gradients Металлические градиенты Meters Метры Method: Метод: + Pad Продлить + Repeats Число повторов Millimeters Миллиметры Minify Минифицировать Minimal Минимальный @@ -5693,6 +5695,9 @@ axis Y Axis Ось Y @msg `dither` must be true or false `dither` должно быть true или false Perceptual Перцепционный @msg `method` must be classic or perceptual `method` должен быть classic или perceptual +@msg `spread` must be pad, repeat or reflect `spread` должен быть pad, repeat или reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` должен быть целым числом от 1 до {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` применяется к линейным, радиальным, коническим и ромбовидным градиентам # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Установить «{name}», которому нужен большой вычислительный бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Каждый запуск может длиться до {minutes} минут и выполнять в {times} раз больше работы, чем разрешено другим модулям. Он работает в фоне, и его можно отменить. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..306270c048 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1935,6 +1935,8 @@ font-technology Variable Variabla Metallic Gradients Metalliska gradienter Meters Meter Method: Metod: + Pad Förläng + Repeats Upprepningar Millimeters Millimeter Minify Minifiera Minimal Minimal @@ -6104,6 +6106,9 @@ axis Y Axis Y-axel @msg `dither` applies to gradient fills (paint.setFill) `dither` gäller gradientfyllningar (paint.setFill) @msg `dither` must be true or false `dither` måste vara true eller false @msg `method` must be classic or perceptual `method` måste vara classic eller perceptual +@msg `spread` must be pad, repeat or reflect `spread` måste vara pad, repeat eller reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` måste vara ett heltal från 1 till {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gäller för linjära, radiella och koniska gradienter och rombgradienter # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installera ”{name}”, som begär en stor beräkningsbudget? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Varje körning kan ta upp till {minutes} minuter och göra {times} gånger så mycket arbete som andra plugins får. Den körs i bakgrunden och du kan avbryta den. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..ff7f70b56f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1828,6 +1828,8 @@ font-technology Variable Değişken Metallic Gradients Metalik Degradeler Meters Metre Method: Yöntem: + Pad Uzat + Repeats Tekrar sayısı Millimeters Milimetre Minify Küçült Minimal Asgari @@ -6038,6 +6040,9 @@ axis Y Axis Y ekseni Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır +@msg `spread` must be pad, repeat or reflect `spread` pad, repeat veya reflect olmalıdır +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 ile {MAX_REPEATS} arasında bir tam sayı olmalıdır +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` doğrusal, radyal, konik ve elmas degradeler için geçerlidir # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Her çalıştırma {minutes} dakikaya kadar sürebilir ve diğer eklentilere izin verilen işin {times} katını yapabilir. Arka planda çalışır ve iptal edebilirsiniz. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..c673605b21 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2026,6 +2026,8 @@ file-filter Templates Шаблони Metallic Gradients Металеві градієнти Meters Метри Method: Метод: + Pad Продовжити + Repeats Кількість повторів Millimeters Міліметри Minify Мінімізувати Minimal Мінімальний @@ -5687,6 +5689,9 @@ axis Y Axis Вісь Y @msg `dither` must be true or false `dither` має бути true або false Perceptual Перцептивний @msg `method` must be classic or perceptual `method` має бути classic або perceptual +@msg `spread` must be pad, repeat or reflect `spread` має бути pad, repeat або reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` має бути цілим числом від 1 до {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` застосовується до лінійних, радіальних, конічних і ромбоподібних градієнтів # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Встановити «{name}», якому потрібен великий обчислювальний бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Кожен запуск може тривати до {minutes} хвилин і виконувати в {times} разів більше роботи, ніж дозволено іншим модулям. Він працює у фоні, і його можна скасувати. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..b5816743bd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2334,6 +2334,8 @@ digits Hindi Hindi Meters Mét Method Phương pháp Method: Phương pháp: + Pad Kéo dài + Repeats Số lần lặp Mezzotint Mezzotint Mezzotint… Mezzotint… Millimeters Milimét @@ -5688,6 +5690,9 @@ axis Y Axis Trục Y @msg `dither` must be true or false `dither` phải là true hoặc false Perceptual Theo cảm nhận @msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual +@msg `spread` must be pad, repeat or reflect `spread` phải là pad, repeat hoặc reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` phải là số nguyên từ 1 đến {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` áp dụng cho chuyển sắc tuyến tính, tỏa tròn, hình nón và hình thoi # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Cài đặt “{name}”, plug-in yêu cầu ngân sách tính toán lớn? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Mỗi lần chạy có thể mất tới {minutes} phút và làm khối lượng việc gấp {times} lần mức cho phép với các plug-in khác. Plug-in chạy trong nền và bạn có thể hủy. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..5cc3853bbb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1903,6 +1903,8 @@ file-filter Templates 模板 Metallic Gradients 金属渐变 Meters 米 Method: 方法: + Pad 延伸 + Repeats 重复次数 Millimeters 毫米 Minify 压缩 Minimal 最少 @@ -5920,6 +5922,9 @@ axis Y Axis Y 轴 @msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill) @msg `dither` must be true or false `dither` 必须是 true 或 false @msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual +@msg `spread` must be pad, repeat or reflect `spread` 必须是 pad、repeat 或 reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必须是 1 到 {MAX_REPEATS} 之间的整数 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 适用于线性、径向、圆锥和菱形渐变 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 要安装需要大量计算预算的“{name}”吗? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次运行最多可能需要 {minutes} 分钟,并可完成其他插件所允许工作量的 {times} 倍。它在后台运行,可以随时取消。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..44c86f9955 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2978,6 +2978,8 @@ file-filter Templates 範本 Metallic Gradients 金屬漸層 Meters 公尺 Method: 方法: + Pad 延伸 + Repeats 重複次數 Millimeters 公釐 Minify 縮減檔案大小 Minimal 最少 @@ -5725,6 +5727,9 @@ axis Y Axis Y 軸 @msg `dither` must be true or false `dither` 必須是 true 或 false Perceptual 感知 @msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual +@msg `spread` must be pad, repeat or reflect `spread` 必須是 pad、repeat 或 reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必須是 1 到 {MAX_REPEATS} 之間的整數 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 適用於線性、放射狀、圓錐和菱形漸層 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 要安裝需要大量運算預算的「{name}」嗎? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次執行最多可能需要 {minutes} 分鐘,並可完成其他增效模組所允許工作量的 {times} 倍。它會在背景執行,您可以隨時取消。 diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 500649b7ed..83f003e150 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -28,9 +28,9 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::gradient::{ - MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, + MAX_REPEATS, MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, }; -use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::{NodeId, StrokeGradientMode}; use super::{active_paint, live_run, pstate, set_pstate}; @@ -315,6 +315,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; } method_row(app, ui, &g, is_grad); + spread_row(app, ui, &g, is_grad); // The proxy beside the angle and aspect ratio fields. ui.horizontal(|ui| { super::proxy(app, ui, 36.0); @@ -397,6 +398,39 @@ fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: }); } +/// The Spread row: what a linear, radial, conic or diamond gradient paints past the ends of its +/// stops (Pad, Repeat or Reflect), and how many times Repeat and Reflect run the stops along the +/// vector (1 to [`MAX_REPEATS`]; dimmed with Pad). +fn spread_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { + let spreads = [(GradientSpread::Pad, tl!("Pad")), (GradientSpread::Repeat, tl!("Repeat")), (GradientSpread::Reflect, tl!("Reflect"))]; + let current = g.gradient.spread; + let shown = spreads.iter().find(|(s, _)| *s == current).map_or("", |(_, l)| *l); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Spread:")); + let labels = spreads.map(|(_, l)| l); + let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-spread", shown, &labels, 80.0)).inner; + if let Some((s, _)) = chosen.and_then(|i| spreads.get(i)) + && *s != current + { + edit(app, json!({"spread": s.label().to_lowercase()}), Live::Released); + } + let on = is_grad && !current.is_pad(); + let n = f64::from(g.gradient.repeat_count()); + let set = ui + .add_enabled_ui(on, |ui| widgets::spin_plain_named(ui, "grad-repeats", Some(tl!("Repeats")), n, "", 0, 56.0, 1.0, 1.0, &REPEAT_PRESETS)) + .inner; + if let Some(r) = set { + let r = r.round().clamp(1.0, f64::from(MAX_REPEATS)); + if r != n { + edit(app, json!({"repeats": r}), Live::Released); + } + } + }); +} + +/// The Repeats field's menu. +const REPEAT_PRESETS: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; + /// The Presets row: a menu of the preset gradients by library (theme), then the /// saved ones; a choice applies to the gradient behind the active proxy, keeping its placement /// (`gradient.presets.apply`; a solid or no paint becomes the gradient). The menu shows the preset diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 27b0eda0b4..589dbc523a 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -328,6 +328,44 @@ fn the_method_menu_makes_the_gradient_perceptual() { assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); } +/// The Spread menu makes the gradient repeat (one undo step), and the Repeats field, dimmed while +/// it pads, then sets the count. +#[test] +fn the_spread_menu_and_repeats_field_set_the_spread() { + use vectorcraft_color::GradientSpread; + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Spread:"), "{texts:?}"); + let shown = texts.iter().find(|(t, _)| t == "Pad").expect("Pad shown").1; + let undo = app.session.doc().unwrap().history.undo.len(); + let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }; + // While it pads, the Repeats field takes no count. + let field = p.widget_after(shown); + p.click(&mut app, field.center()); + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("4".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert_eq!((fill(&app).gradient.repeats, app.session.doc().unwrap().history.undo.len()), (1, undo), "dimmed with Pad"); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Pad").expect("Pad shown").1; + let texts = p.click(&mut app, shown); + let option = texts.iter().find(|(t, _)| t == "Reflect").expect("the menu lists Reflect").1; + p.click(&mut app, option); + assert_eq!(fill(&app).gradient.spread, GradientSpread::Reflect); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The count: typed into the field beside the menu. + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Reflect").expect("the menu shows Reflect").1; + let field = p.widget_after(shown); + p.click(&mut app, field.center()); + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("4".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert_eq!(fill(&app).gradient.repeat_count(), 4); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(&app).gradient.spread, GradientSpread::Pad); +} + #[test] fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 017ba588ce..87c6034bda 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -111,7 +111,7 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: } (0..n) .map(|k| { - let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + let t = gp.gradient.spread_param(geom.param_at(gp.gradient.kind, at(k))).clamp(0.0, 1.0) as f32; rgba(gp.gradient.sample(t)) }) .collect() diff --git a/docs/gaps.md b/docs/gaps.md index 83afa40942..feedfa8839 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -414,9 +414,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** COLR colour fonts (v0 and v1): a glyph's layers are vector art (solid colours, linear and radial gradients, clips, blend modes, palette 0, the foreground colour from the run's fill), placed with the type, drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF, and editable - paths with Create Outlines. Limits: glyphs using sweep gradients, Porter-Duff compositing, radial gradients from a - circle that isn't the centre, or repeating gradients are images of the glyph until documents have those paints; - estimate unchanged. + paths with Create Outlines. Limits: glyphs using sweep gradients, Porter-Duff compositing or radial gradients from a + circle that isn't the centre are images of the glyph until documents have those paints, and glyphs using repeating + or reflecting gradients are images until the color font reader maps them to a gradient's spread; estimate unchanged. - **Done:** OpenType SVG colour fonts: each glyph's SVG document is read by the SVG importer into vector art, in the run's colours where it uses `currentColor` (or the legacy `context-fill`/`context-stroke`), then drawn, exported and outlined as COLR glyphs are (PDF real text writes them as text that draws nothing, their art drawn where they sit). Images in glyph documents diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..b9d903e76e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -794,7 +794,7 @@ symbol (named after its `data-name`, else its id) with an instance per ``; symbol with strokes) stays art. `feGaussianBlur`, `feDropShadow` and the usual shadow chains (offset, blur, flood or colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer Glow, Inner Glow and Feather effects; other filters are listed in `warnings`. Undisplayed objects (`display:none`) come back hidden, nested clip paths intersect, -and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `` files (relative links are +and reflected or repeated gradients (`spreadMethod`) import as gradients that repeat or reflect (see Spread under Examples). `` files (relative links are found in the SVG's folder; only files on this computer or the SVG's network share are read, and pasted SVG reads only absolute paths) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`. @@ -1143,6 +1143,41 @@ another form gives a warning: {"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"kind":"diamond","start":[150,150],"end":[200,150],"aspect":60}}}} ``` +Spread (the Gradient panel's menu, beside a Repeats field) says what a linear, radial, conic or diamond gradient +paints past the ends of its stops. `pad` (the default) carries the first and last colors on. `repeat` and `reflect` +run the stops `repeats` times along the vector (1 to 100, VectorCraft's own limit; default 1) and keep going past +both ends of it, `repeat` starting the stops again each time and `reflect` running every other run backwards. A +radial or diamond gradient repeats in rings or squares out from its center (an off-center radial's rings grow about +its focal point), and a conic one repeats around the turn. A freeform gradient has no ends, so a `spread` other +than `pad` is an error for one, and so is a `repeats` that is not a whole number from 1 to 100. Both edit as one +undo step, and an edit that changes nothing records none. A gradient set back to `pad` keeps its count for later, +and it matches a preset or swatch of the same gradient whatever count it keeps. The Gradient tool marks each place +along the bar where one run meets the next (on a conic, across its extent ellipse). A gradient along or across a +stroke runs its stops `repeats` times along or across it, a blend between two gradients that repeat (or reflect) +steps the count from one to the other, and a gradient swatch saved as CSS gets its stops repeated across the box. The +Gradient tool's stop and midpoint markers lie along the whole bar, as on the Gradient panel's slider, and edit the +stops of one run. + +SVG export writes `spreadMethod` with the gradient's vector shortened to one run +(https://www.w3.org/TR/SVG2/pservers.html), and SVG import reads +`spreadMethod` as the spread, with a count of 1. CSS writes `repeating-linear-gradient` and `repeating-radial-gradient` over one run, or +one run and its mirror for `reflect` (https://www.w3.org/TR/css-images-3/#repeating-gradients), and a conic gradient +with its stops repeated around the turn. PDF writes a conic gradient's sweep shading over one run, repeated or +reflected past it. PDF, EPS and legacy `.ai` gradients pad past their ends, so a linear, radial or diamond gradient +is written with its stops repeated as many times as the shape needs: at most 256 runs and 16,384 stops (256 stops in +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: + +```json +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}} +``` + Dither (the Gradient panel's box) draws a linear, radial, conic or diamond gradient fill with fine noise that hides banding; SVG and PDF exports write such a fill as an image at the Document Raster Effects resolution. It applies to fills (`stroke: true` is refused): diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..7b7e0e1315 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1337,6 +1337,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). - Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`. - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient. + - Gradient spread, VectorCraft's own setting: Pad, Repeat or Reflect, with a Repeats count of 1 to 100, in the Gradient panel and on `paint.editGradient` and `paint.setFill`; Repeat and Reflect run a linear, radial, conic or diamond gradient's stops that many times along the vector and on past its ends, the Gradient tool marks where the runs meet, and SVG writes and reads `spreadMethod`, CSS writes repeating gradients, PDF writes a conic's sweep shading over one run and the other kinds with their stops repeated across the shape, as EPS, legacy `.ai` and the `.ai` editing data do. - The stop popover a double-click on a stop opens (the Gradient tool's annotator or the Gradient panel) has an eyedropper beside its Color and Swatches tabs: it closes the popover and samples the next colour clicked on the art into the stop, as the Gradient panel's eyedropper does (#4681). Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.