diff --git a/ASSETS.md b/ASSETS.md
index 17ab680ff4..27bbcfc5e7 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -159,6 +159,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-fx.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-gamut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-grad-conic.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/assets/icons/dc-grad-conic.svg b/assets/icons/dc-grad-conic.svg
new file mode 100644
index 0000000000..f6e49150be
--- /dev/null
+++ b/assets/icons/dc-grad-conic.svg
@@ -0,0 +1 @@
+
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index fd2d6dc71a..d0a641697d 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -35,7 +35,7 @@ Affinity files.
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
-transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
+transparency gradients, fill opacity, bitmap fills, master pages based on other master pages,
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs
index 03d0f5670c..18aa1bd189 100644
--- a/crates/color/src/gradient.rs
+++ b/crates/color/src/gradient.rs
@@ -15,20 +15,36 @@ pub enum GradientKind {
Radial,
/// Freeform gradients: colour points and lines (see [`crate::freeform`]).
Freeform,
+ /// Conic (angle) gradients: the stops sweep once around the center (`start`), clockwise on
+ /// the page from the vector, with a seam where the last stop meets the first. A negative
+ /// `aspect` mirrors the sweep (see [`GradientGeom::transform`]).
+ Conic,
}
impl GradientKind {
- /// Parse a kind name (`linear`, `radial`, `freeform`; any case).
+ /// Every kind, in the order the Gradient panel lists them.
+ pub const ALL: [GradientKind; 4] = [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Freeform];
+ /// Parse a kind name (`linear`, `radial`, `conic`, `freeform`; any case). `angle` and
+ /// `conical` also mean conic.
pub fn parse(s: &str) -> Option {
- [GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
+ if s.eq_ignore_ascii_case("angle") || s.eq_ignore_ascii_case("conical") {
+ return Some(GradientKind::Conic);
+ }
+ Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
GradientKind::Linear => "Linear",
GradientKind::Radial => "Radial",
GradientKind::Freeform => "Freeform",
+ GradientKind::Conic => "Conic",
}
}
+ /// Is it placed by a center (`start`) and an extent ellipse (radial and conic), rather than
+ /// by a line?
+ pub fn is_centered(self) -> bool {
+ matches!(self, GradientKind::Radial | GradientKind::Conic)
+ }
}
/// How a gradient mixes the colours between its stops (the Gradient panel's Method).
@@ -642,19 +658,23 @@ pub struct GradientGeom {
/// How far out a focal point may sit, as a fraction of the extent ellipse.
const FOCAL_MAX: f64 = 0.99;
+/// Most wedges [`GradientGeom::conic_wedges`] draws a conic gradient as.
+pub const MAX_CONIC_WEDGES: usize = 4096;
+
fn one64() -> f64 {
1.0
}
impl GradientGeom {
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
- /// radial is centred with radius = half the larger dimension and its vector at `angle_deg`.
+ /// radial and conic are centered with radius = half the larger dimension and their vector at
+ /// `angle_deg`.
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
let c = b.center();
let a = angle_deg.to_radians();
let d = Vec2::new(a.cos(), -a.sin());
match kind {
- GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
+ GradientKind::Radial | GradientKind::Conic => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
_ => {
// Project the box corners onto the direction to cover the whole box.
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
@@ -668,7 +688,12 @@ impl GradientGeom {
/// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the
/// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse
/// (the radius along the vector, `aspect` × the radius across it); its principal axes give the
- /// new end (on the axis nearest the mapped vector) and aspect.
+ /// new end (on the axis nearest the mapped vector) and aspect. Conic gradients keep their
+ /// vector exactly (where the sweep starts, so the seam stays in place) and take the mapped
+ /// extent across it as their aspect, its sign flipping under a mirror so the sweep turns the
+ /// other way: exact for moves, rotations, uniform scales and mirrors, and the nearest frame
+ /// that keeps the seam under a shear or a non-uniform scale. A conic whose vector has no
+ /// length, or whose aspect is 0, still has the sign of its aspect flipped by a mirror.
pub fn transform(&mut self, a: Affine, kind: GradientKind) {
// The focal point is a point like any other.
self.focal = self.focal.map(|f| a * f);
@@ -677,9 +702,26 @@ impl GradientGeom {
let perp = |v: Vec2| Vec2::new(-v.y, v.x);
let start = a * self.start;
let u = self.end - self.start;
+ let mirror = m0 * m3 - m1 * m2 < 0.0;
if u.hypot2() < 1e-18 {
self.start = start;
self.end = start;
+ if kind == GradientKind::Conic && mirror {
+ self.aspect = -self.aspect;
+ }
+ return;
+ }
+ if kind == GradientKind::Conic {
+ let (lu, lw) = (lin(u), lin(perp(u) * self.aspect));
+ self.start = start;
+ self.end = start + lu;
+ let aspect = lw.dot(perp(lu)) / lu.hypot2();
+ self.aspect = match (lu.hypot2() > 1e-18 && aspect.is_finite(), aspect == 0.0) {
+ (false, _) => 1.0,
+ // A flat extent keeps its sign, so a mirror still turns the sweep the other way.
+ (true, true) => 0.0f64.copysign(if self.aspect.is_sign_negative() != mirror { -1.0 } else { 1.0 }),
+ (true, false) => aspect,
+ };
return;
}
if kind != GradientKind::Radial {
@@ -724,8 +766,15 @@ impl GradientGeom {
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial,
/// measured from the focal point (each level is the ellipse scaled about the line from the
- /// focal point to the centre).
+ /// focal point to the centre). For conic gradients it is the turn from the vector in
+ /// [`Self::conic_frame`], clockwise on the page (counterclockwise when mirrored), in 0..=1.
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
+ if kind == GradientKind::Conic {
+ let q = (self.conic_frame().inverse() * p).to_vec2();
+ let t = q.y.atan2(q.x) / std::f64::consts::TAU;
+ let t = if t < 0.0 { t + 1.0 } else { t };
+ return if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
+ }
let u = self.end - self.start;
if u.hypot2() < 1e-18 {
return 0.0;
@@ -746,13 +795,72 @@ impl GradientGeom {
(-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a
}
- /// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to
- /// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse).
+ /// The map from the unit circle onto a radial's (or conic's) extent ellipse: (1, 0) to the
+ /// end, (0, 1) to `aspect` × the radius across the vector (a conic's negative aspect mirrors
+ /// it). None when the ellipse is flat (no inverse).
pub fn unit_frame(&self) -> Option {
let u = self.end - self.start;
(u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y]))
}
+ /// The frame a conic gradient sweeps in: its [`Self::unit_frame`], or, when the extent
+ /// ellipse is flat or not finite (a vector without length, an aspect of 0, or a file's
+ /// out-of-range numbers), the unit circle about the start turning from the vector's direction
+ /// (from the right when the vector has none), mirrored by a negative aspect. The canvas,
+ /// Expand and every export draw a conic in this frame.
+ pub fn conic_frame(&self) -> Affine {
+ if let Some(m) = self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18) {
+ return m;
+ }
+ if !self.start.is_finite() {
+ return Affine::IDENTITY;
+ }
+ let u = self.end - self.start;
+ let len = u.hypot();
+ let d = if len.is_finite() && len > 1e-9 { u / len } else { Vec2::new(1.0, 0.0) };
+ let s = if self.aspect.is_sign_negative() && !self.aspect.is_nan() { -1.0 } else { 1.0 };
+ Affine::new([d.x, d.y, -d.y * s, d.x * s, self.start.x, self.start.y])
+ }
+
+ /// Is the sweep mirrored (counterclockwise on the page): a conic's negative aspect?
+ pub fn is_mirrored(&self) -> bool {
+ self.conic_frame().determinant() < 0.0
+ }
+
+ /// A conic gradient as `n` solid wedges around its center (at most [`MAX_CONIC_WEDGES`]), for
+ /// formats without conic gradients: each wedge's outline (the center, then an arc that
+ /// reaches past every corner of `b`) and the turn its color is sampled at (its middle). With
+ /// `overlap` (opaque stops), each wedge runs on to the seam and the next is drawn over it, so
+ /// no hairline of what lies below shows between neighbors and the seam stays sharp.
+ pub fn conic_wedges(&self, b: Rect, n: usize, overlap: bool) -> Vec<(Vec, f64)> {
+ let n = n.clamp(1, MAX_CONIC_WEDGES);
+ let frame = self.conic_frame();
+ let to_unit = frame.inverse();
+ let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)];
+ let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6);
+ let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 };
+ // Arc points at most ARC of a turn apart, at a radius where their chords stay outside
+ // `reach`.
+ const ARC: f64 = 1.0 / 32.0;
+ let r = reach / (std::f64::consts::PI * ARC).cos();
+ let at = |turn: f64| {
+ let a = turn * std::f64::consts::TAU;
+ frame * Point::new(r * a.cos(), r * a.sin())
+ };
+ let center = frame * Point::ORIGIN;
+ (0..n)
+ .map(|k| {
+ let from = k as f64 / n as f64;
+ let to = if overlap { 1.0 } else { (k + 1) as f64 / n as f64 };
+ let steps = ((to - from) / ARC).ceil().max(1.0) as usize;
+ let mut pts = Vec::with_capacity(steps + 2);
+ pts.push(center);
+ pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64)));
+ (pts, (k as f64 + 0.5) / n as f64)
+ })
+ .collect()
+ }
+
/// The map that squashes a radial gradient's circle (centre `start`, radius the vector's
/// length) into its extent ellipse: by `aspect` across the vector, about the centre.
pub fn radial_squash(&self) -> Affine {
@@ -901,7 +1009,7 @@ impl GradientPaint {
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
}
- /// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
+ /// A linear, radial or conic gradient placed on box `bounds` as a `cols` × `rows` grid of
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a
/// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets
@@ -932,7 +1040,7 @@ impl GradientPaint {
) -> impl Iterator- + use<> {
self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false)
}
- /// [`Self::pixels`] of a linear or radial gradient whose stops are all CMYK, as C, M, Y, K
+ /// [`Self::pixels`] of a linear, radial or conic gradient whose stops are all CMYK, as C, M, Y, K
/// and opacity levels mixed from the stops' inks; `None` when a stop is in another model,
/// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`].
/// Dither's noise adds no ink where the ramp has none.
diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs
index 87372a1c24..c8d0a4bb64 100644
--- a/crates/color/src/gradient/tests.rs
+++ b/crates/color/src/gradient/tests.rs
@@ -697,3 +697,186 @@ fn ink_pixels_stop_at_the_ink_mixes_limit() {
assert!(GradientPaint::new(perceptual).ink_pixels(b, b, 2, 1, false).is_none());
assert!(GradientPaint::new(classic).ink_pixels(b, b, 2, 1, false).is_some());
}
+
+/// A conic placed at `center` with its vector `dx` long to the right and aspect `aspect`.
+fn conic_at(center: Point, dx: f64, aspect: f64) -> GradientGeom {
+ GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None }
+}
+
+#[test]
+fn a_conic_turns_clockwise_on_the_page_from_its_vector() {
+ // The page's y runs down: a quarter turn clockwise from a vector pointing right is straight
+ // below the center.
+ let g = conic_at(Point::new(100.0, 100.0), 50.0, 1.0);
+ let t = |x: f64, y: f64| g.param_at(GradientKind::Conic, Point::new(x, y));
+ assert!(t(200.0, 100.0).abs() < 1e-12);
+ assert!((t(100.0, 130.0) - 0.25).abs() < 1e-12);
+ assert!((t(10.0, 100.0) - 0.5).abs() < 1e-12);
+ assert!((t(100.0, 3.0) - 0.75).abs() < 1e-12);
+ // Either side of the seam: the start of the ramp just below the vector, its end just above.
+ assert!(t(200.0, 100.01) < 1e-3);
+ let before = t(200.0, 99.99);
+ assert!(before > 0.999 && before <= 1.0, "{before}");
+ // A flatter extent ellipse turns faster near the vector's line: half as tall, the point 1/8
+ // of a turn around is half as far below it.
+ let squashed = conic_at(Point::new(100.0, 100.0), 50.0, 0.5);
+ let p = Point::new(150.0, 125.0);
+ assert!((squashed.param_at(GradientKind::Conic, p) - 0.125).abs() < 1e-12);
+ assert!(g.param_at(GradientKind::Conic, p) < 0.1);
+ // Mirrored (a negative aspect), it turns counterclockwise.
+ let mirrored = conic_at(Point::new(100.0, 100.0), 50.0, -1.0);
+ assert!(mirrored.is_mirrored() && !g.is_mirrored());
+ assert!((mirrored.param_at(GradientKind::Conic, Point::new(100.0, 70.0)) - 0.25).abs() < 1e-12);
+}
+
+#[test]
+fn a_conic_follows_moves_turns_scales_and_mirrors() {
+ let c = Point::new(10.0, 20.0);
+ let g0 = conic_at(c, 30.0, 1.0);
+ let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0)];
+ for a in [
+ Affine::rotate(0.7),
+ Affine::scale(2.5),
+ Affine::translate((4.0, -9.0)) * Affine::rotate(-2.0),
+ Affine::FLIP_X,
+ Affine::FLIP_Y * Affine::rotate(1.1),
+ Affine::scale_non_uniform(2.0, 0.5),
+ ] {
+ let mut g = g0;
+ g.transform(a, GradientKind::Conic);
+ assert!(g.start.distance(a * c) < 1e-9, "{a:?}");
+ for p in probes {
+ let (want, got) = (g0.param_at(GradientKind::Conic, p), g.param_at(GradientKind::Conic, a * p));
+ assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}");
+ }
+ }
+ // A mirror and a mirror back: the same sweep.
+ let mut g = g0;
+ g.transform(Affine::FLIP_X, GradientKind::Conic);
+ assert!(g.is_mirrored());
+ g.transform(Affine::FLIP_X, GradientKind::Conic);
+ assert!(!g.is_mirrored() && (g.aspect - 1.0).abs() < 1e-12);
+ // A conic without a vector, or with a flat extent, turns the other way under a mirror too.
+ for flat in [conic_at(c, 0.0, 1.0), conic_at(c, 30.0, 0.0)] {
+ let mut g = flat;
+ g.transform(Affine::FLIP_Y, GradientKind::Conic);
+ assert!(g.is_mirrored(), "{g:?}");
+ g.transform(Affine::rotate(0.3), GradientKind::Conic);
+ assert!(g.is_mirrored(), "{g:?}");
+ g.transform(Affine::FLIP_X, GradientKind::Conic);
+ assert!(!g.is_mirrored(), "{g:?}");
+ }
+ // Under a shear the seam stays where the vector maps.
+ let shear = Affine::new([1.0, 0.0, 0.6, 1.0, 0.0, 0.0]);
+ let mut g = g0;
+ g.transform(shear, GradientKind::Conic);
+ let on_seam = shear * (c + Vec2::new(20.0, 0.0));
+ assert!(g.param_at(GradientKind::Conic, on_seam) < 1e-9, "{g:?}");
+}
+
+#[test]
+fn conic_kind_names_and_fit() {
+ for s in ["conic", "Conic", "angle", "CONICAL"] {
+ assert_eq!(GradientKind::parse(s), Some(GradientKind::Conic), "{s}");
+ }
+ assert_eq!(GradientKind::parse("spiral"), None);
+ assert_eq!(GradientKind::Conic.label(), "Conic");
+ assert!(GradientKind::Conic.is_centered() && GradientKind::Radial.is_centered() && !GradientKind::Linear.is_centered());
+ let fit = GradientGeom::fit(GradientKind::Conic, Rect::new(0.0, 0.0, 200.0, 100.0), 90.0);
+ assert_eq!(fit.start, Point::new(100.0, 50.0));
+ assert!(fit.end.distance(Point::new(100.0, -50.0)) < 1e-9);
+}
+
+/// Is `p` inside polygon `poly` (non-zero winding)?
+fn inside(poly: &[Point], p: Point) -> bool {
+ let mut w = 0i32;
+ for (i, &a) in poly.iter().enumerate() {
+ let c = poly[(i + 1) % poly.len()];
+ let cross = (c - a).cross(p - a);
+ if a.y <= p.y && c.y > p.y && cross > 0.0 {
+ w += 1;
+ } else if a.y > p.y && c.y <= p.y && cross < 0.0 {
+ w -= 1;
+ }
+ }
+ w != 0
+}
+
+#[test]
+fn conic_wedges_cover_the_box_and_meet_at_the_seam() {
+ let b = Rect::new(0.0, 0.0, 200.0, 100.0);
+ let g = conic_at(Point::new(60.0, 40.0), 30.0, 0.5);
+ let wedges = g.conic_wedges(b, 36, false);
+ assert_eq!(wedges.len(), 36);
+ // Each point of the box lies in the wedge whose share of the turn holds it.
+ for (x, y) in [(1.0, 1.0), (199.0, 99.0), (150.0, 30.0), (10.0, 90.0), (61.0, 39.0), (59.0, 41.0)] {
+ let p = Point::new(x, y);
+ let t = g.param_at(GradientKind::Conic, p);
+ let k = ((t * 36.0).floor() as usize).min(35);
+ assert!(inside(&wedges[k].0, p), "({x}, {y}) at {t} is not in wedge {k}");
+ assert!((wedges[k].1 - (k as f64 + 0.5) / 36.0).abs() < 1e-12);
+ }
+ // Overlapping wedges run on from their own start to the seam.
+ let over = g.conic_wedges(b, 36, true);
+ let frame = g.unit_frame().unwrap();
+ let on = |turn: f64| frame * Point::new(0.5 * (turn * std::f64::consts::TAU).cos(), 0.5 * (turn * std::f64::consts::TAU).sin());
+ assert!(inside(&over[0].0, on(0.5)) && inside(&over[0].0, on(35.9 / 36.0)));
+ assert!(inside(&over[20].0, on(35.9 / 36.0)) && !inside(&over[20].0, on(19.9 / 36.0)));
+ // The count is kept to what a file can hold.
+ assert_eq!(g.conic_wedges(b, 0, false).len(), 1);
+ assert_eq!(g.conic_wedges(b, usize::MAX, false).len(), MAX_CONIC_WEDGES);
+}
+
+/// A conic whose extent can't be drawn (a vector without length, an aspect of 0, or numbers out
+/// of range, as a file can hold them) sweeps from the right about its start, or the origin; the
+/// parameter, the frame and the wedges stay finite.
+#[test]
+fn a_conic_with_no_extent_or_bad_numbers_still_sweeps() {
+ let c = Point::new(50.0, 50.0);
+ for g in [
+ conic_at(c, 0.0, 1.0),
+ conic_at(c, 30.0, 0.0),
+ conic_at(c, 30.0, f64::NAN),
+ conic_at(c, 30.0, f64::INFINITY),
+ conic_at(c, f64::INFINITY, 1.0),
+ GradientGeom { end: Point::new(f64::NAN, 3.0), ..conic_at(c, 30.0, 1.0) },
+ ] {
+ assert!(g.conic_frame().as_coeffs().iter().all(|v| v.is_finite()), "{g:?}");
+ let t = |x: f64, y: f64| g.param_at(GradientKind::Conic, Point::new(x, y));
+ assert!(t(90.0, 50.0).abs() < 1e-12, "{g:?}");
+ assert!((t(50.0, 90.0) - 0.25).abs() < 1e-12, "{g:?}");
+ let wedges = g.conic_wedges(Rect::new(0.0, 0.0, 100.0, 100.0), 4, false);
+ assert!(wedges.iter().flat_map(|(pts, _)| pts).all(|p| p.is_finite()), "{g:?}");
+ }
+ // A flat extent keeps the vector's direction and the sign of its aspect.
+ let down = GradientGeom { end: Point::new(50.0, 80.0), ..conic_at(c, 30.0, 0.0) };
+ assert!(down.param_at(GradientKind::Conic, Point::new(50.0, 90.0)).abs() < 1e-12);
+ assert!((down.param_at(GradientKind::Conic, Point::new(10.0, 50.0)) - 0.25).abs() < 1e-12);
+ for g in [conic_at(c, 0.0, -1.0), conic_at(c, 30.0, -0.0), conic_at(c, 30.0, f64::NEG_INFINITY)] {
+ assert!(g.is_mirrored(), "{g:?}");
+ assert!((g.param_at(GradientKind::Conic, Point::new(50.0, 10.0)) - 0.25).abs() < 1e-12, "{g:?}");
+ }
+ let lost = GradientGeom { start: Point::new(f64::NAN, 0.0), ..conic_at(c, 30.0, 1.0) };
+ assert_eq!(lost.conic_frame(), Affine::IDENTITY);
+ for p in [Point::new(3.0, 4.0), Point::new(f64::NAN, 1.0), Point::new(f64::INFINITY, 0.0)] {
+ let t = lost.param_at(GradientKind::Conic, p);
+ assert!((0.0..=1.0).contains(&t), "{t}");
+ }
+}
+
+/// A conic's pixels (what SVG and PDF draw a dithered or Perceptual one as) follow the sweep,
+/// with one stop as well as many.
+#[test]
+fn conic_pixels_follow_the_sweep() {
+ let b = Rect::new(0.0, 0.0, 4.0, 4.0);
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Conic, Gradient::default().stops));
+ g.geom = Some(conic_at(Point::new(2.0, 2.0), 1.0, 1.0));
+ let px: Vec<[u8; 4]> = g.pixels(b, b, 4, 4, true, &Color::to_rgb).collect();
+ // Below right of the center, early in the sweep: light. Above right, late: dark.
+ assert!(px[3 * 4 + 3][0] > 180 && px[3][0] < 75, "{px:?}");
+ let one = GradientPaint { gradient: Gradient::new(GradientKind::Conic, vec![GradientStop::new(0.3, Color::rgb(1.0, 0.0, 0.0))]), ..g.clone() };
+ assert!(one.pixels(b, b, 4, 4, true, &Color::to_rgb).all(|p| p == [255, 0, 0, 255]));
+ let many = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray((i % 2) as f32))).collect();
+ let many = GradientPaint { gradient: Gradient::new(GradientKind::Conic, many), ..g };
+ assert_eq!(many.pixels(b, b, 4, 4, true, &Color::to_rgb).count(), 16);
+}
diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs
index b331d43e6a..6be0f2abf5 100644
--- a/crates/color/src/palette_io.rs
+++ b/crates/color/src/palette_io.rs
@@ -189,10 +189,22 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
let value = match &w.paint {
Paint::Solid { color, .. } => css_color(color, 1.0),
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
+ // A mirrored conic turns counterclockwise, and CSS only turns clockwise: its stops
+ // are written mirrored about the seam instead.
+ let mirrored = g.gradient.kind == GradientKind::Conic && g.geom.is_some_and(|geom| geom.is_mirrored());
+ let stop = |s: &crate::GradientStop| {
+ let at = if mirrored { 1.0 - s.offset } else { s.offset };
+ format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0)
+ };
let stops: Vec
=
- g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect();
+ if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.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
+ // from the right.
+ GradientKind::Conic => {
+ format!("conic-gradient(from {}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", "))
+ }
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
}
}
diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs
index b0342d7a52..9ee07cb623 100644
--- a/crates/color/src/palette_io/tests.rs
+++ b/crates/color/src/palette_io/tests.rs
@@ -166,6 +166,26 @@ fn css_escapes_names_and_writes_gradients() {
assert!(read(&text, "x").is_err(), "CSS is written only");
}
+/// A conic gradient swatch is a CSS conic gradient starting where its angle points (CSS turns
+/// clockwise from straight up); a mirrored one, which turns counterclockwise, has its stops mirrored
+/// about the seam so each color lies on the same side.
+#[test]
+fn css_writes_conic_gradient_swatches() {
+ let one = |name: &str, g: GradientPaint| SwatchLibrary {
+ name: "x".into(),
+ swatches: vec![Swatch { name: name.into(), paint: Paint::Gradient(Box::new(g)), global: false, spot: false }],
+ groups: vec![],
+ };
+ let mut conic = GradientPaint::new(Gradient { kind: GradientKind::Conic, ..Gradient::default() });
+ conic.angle = 90.0;
+ let text = written(&one("Sweep", conic.clone()), PaletteFormat::Css);
+ assert!(text.contains("--sweep: conic-gradient(from 0deg, #ffffff 0%, #000000 100%);"), "{text}");
+ conic.gradient.stops[0].offset = 0.2;
+ conic.geom = Some(crate::GradientGeom { start: kurbo::Point::ZERO, end: kurbo::Point::new(0.0, -10.0), aspect: -1.0, focal: None });
+ let text = written(&one("Back", conic), PaletteFormat::Css);
+ assert!(text.contains("--back: conic-gradient(from 0deg, #000000 0%, #ffffff 80%);"), "{text}");
+}
+
#[test]
fn css_preserves_fractional_gradient_stop_positions() {
for kind in [GradientKind::Linear, GradientKind::Radial] {
diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs
index 2e19ad44a3..c057aeb231 100644
--- a/crates/doc/src/appearance.rs
+++ b/crates/doc/src/appearance.rs
@@ -444,13 +444,13 @@ impl Appearance {
}
}
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
- /// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform
- /// points each follow the warp at their own position.
+ /// at each gradient's center: the start of a radial or conic, the middle of a linear vector).
+ /// Freeform points each follow the warp at their own position.
pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
for g in self.gradients_mut() {
let freeform = g.freeform.take();
if let Some(geom) = g.geom {
- let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) };
+ let c = if g.gradient.kind.is_centered() { geom.start } else { geom.start.midpoint(geom.end) };
g.transform(near(c));
}
g.freeform = freeform.map(|mut f| {
diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs
index 8dce25b11a..935d5e9dc5 100644
--- a/crates/engine/src/cmd/expand.rs
+++ b/crates/engine/src/cmd/expand.rs
@@ -36,6 +36,9 @@ pub const MAX_STEPS: u32 = 1000;
const FREEFORM_MESH: u32 = 8;
/// Columns (around the centre) of the mesh a radial gradient expands to.
const RADIAL_MESH_COLS: usize = 8;
+/// Largest turn between a conic gradient's mesh columns, so their straight outer edges stay
+/// close enough to a circle to reach past the box.
+const CONIC_MESH_GAP: f64 = 1.0 / 32.0;
pub fn specs() -> Vec {
vec![
@@ -44,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) | \"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) | \"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}",
has_selection,
expand
),
@@ -1279,7 +1282,10 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32
/// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that
/// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial:
/// `0..t1`, each level's ellipse centred on the way from the focal point to the start).
+/// A conic gradient also has its placement, the box and the radius in its frame that reaches
+/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center).
struct Frame {
+ conic: Option<(vectorcraft_color::GradientGeom, Rect, f64)>,
radial: bool,
start: Point,
focal: Point,
@@ -1309,7 +1315,15 @@ impl Frame {
}
// Keep a sliver of span so the art maps back invertibly.
t1 = t1.max(t0 + 1e-6);
- Self { radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 }
+ let conic = (g.gradient.kind == GradientKind::Conic).then(|| {
+ let to_unit = geom.conic_frame().inverse();
+ let reach = corners.iter().map(|c| (to_unit * *c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6);
+ // Past the corners, and far enough out that the chords between columns at most
+ // CONIC_MESH_GAP of a turn apart stay outside them.
+ let reach = if reach.is_finite() { reach * 1.01 / (std::f64::consts::PI * CONIC_MESH_GAP).cos() } else { 1.0 };
+ (geom, bounds, reach)
+ });
+ Self { conic, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 }
}
/// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t`
@@ -1325,6 +1339,18 @@ impl Frame {
/// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour
/// changes, so it paints the gradient exactly.
fn mesh(&self, g: &Gradient) -> GradientMesh {
+ if let Some((geom, _, reach)) = &self.conic {
+ // Columns around the center where the color changes, at most CONIC_MESH_GAP of a turn
+ // apart so the outer edge stays past the box; the seam is two columns in one place.
+ let br = breaks(g, 0.0, 1.0, CONIC_MESH_GAP);
+ let us: Vec = br.iter().map(|(t, _)| *t).collect();
+ let frame = geom.conic_frame();
+ let at = |u: f64, v: f64| {
+ let a = u * std::f64::consts::TAU;
+ frame * Point::new(reach * v * a.cos(), reach * v * a.sin())
+ };
+ return GradientMesh::from_grid(&us, &[0.0, 1.0], &at, &|_, c, _| br[c].1);
+ }
// Rings as close as the columns around them, so the mesh draws smoothly.
let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY };
let br = breaks(g, self.t0, self.t1, gap);
@@ -1340,13 +1366,21 @@ impl Frame {
/// `n` solid shapes covering the box in paint order, each coloured as the gradient in the
/// middle of its band: for a linear gradient, rectangles from each band's start to the far
- /// end, for a radial one, nested ellipses (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.
+ /// 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. 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))> {
+ let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
+ if let Some((geom, b, _)) = &self.conic {
+ return geom
+ .conic_wedges(*b, n, opaque)
+ .into_iter()
+ .map(|(pts, t)| (PathData::single(SubPath::polyline(&pts, true)), g.sample_keep_model(t as f32)))
+ .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 opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
if self.radial {
let ellipse = |t: f64| {
let c = self.focal + (self.start - self.focal) * t;
diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs
index ed2b44ad3a..c81877632c 100644
--- a/crates/engine/src/cmd/fileio/affinity/paint.rs
+++ b/crates/engine/src/cmd/fileio/affinity/paint.rs
@@ -188,17 +188,24 @@ impl Builder {
let kind = match g.kind {
AfKind::Linear => GradientKind::Linear,
AfKind::Radial => GradientKind::Radial,
- AfKind::Conical => {
- self.warn("conical gradients import as radial gradients");
- GradientKind::Radial
- }
+ AfKind::Conical => GradientKind::Conic,
};
let (u, v): (Vec2, Vec2) = (x - o, y - o);
- if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 {
+ if kind.is_centered() && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 {
self.warn("skewed elliptical gradients import as upright ellipses");
}
let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 };
- let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None };
+ let geom = match kind {
+ // A conic sweeps from unit +x towards unit +y: its unit frame carried onto the
+ // page as a transform carries it (mirrored when the transform is a reflection).
+ GradientKind::Conic => {
+ let mut geom = GradientGeom { start: Point::ZERO, end: Point::new(1.0, 0.0), aspect: 1.0, focal: None };
+ geom.transform(m, GradientKind::Conic);
+ geom
+ }
+ GradientKind::Radial => GradientGeom { start: o, end: x, aspect, focal: None },
+ _ => GradientGeom { start: o, end: x, aspect: 1.0, focal: None },
+ };
let mut gp = GradientPaint::new(Gradient::new(kind, stops));
gp.angle = (-(u.y)).atan2(u.x).to_degrees();
gp.geom = Some(geom);
diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs
index 05ca07019f..d5f5149bab 100644
--- a/crates/engine/src/cmd/fileio/tests_affinity.rs
+++ b/crates/engine/src/cmd/fileio/tests_affinity.rs
@@ -267,6 +267,66 @@ mod native {
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
}
+ /// A version-12 document at 144 ppi holding a 100 px square filled with a red-to-blue conical
+ /// gradient centered at (50, 40) px, 40 px across: its unit +y points down the page, or up
+ /// (`mirror`).
+ fn conical_document(mirror: bool) -> Vec {
+ let stop = |id: u32, c: F| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]);
+ let positions = [[0.0f32, 0.5], [1.0, 0.5]].iter().map(|p| p.iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
+ let grad = F::Obj(
+ tag(b"Grad"),
+ vec![
+ (tag(b"Cols"), F::Shared(vec![stop(23, rgba(1.0, 0.0, 0.0, 1.0)), stop(24, rgba(0.0, 0.0, 1.0, 1.0))])),
+ (tag(b"Posn"), F::Records(8, positions)),
+ ],
+ );
+ let fill = F::Def(22, vec![tag(b"FilG")], vec![(tag(b"Type"), F::Enum(3, 0)), (tag(b"Grad"), grad)]);
+ let unit = F::F64s(vec![40.0, 0.0, 50.0, 0.0, if mirror { -40.0 } else { 40.0 }, 40.0]);
+ let gradient = F::Def(21, vec![tag(b"FDsc")], vec![(tag(b"FDeX"), unit), (tag(b"FDeF"), fill)]);
+ let square = F::Def(
+ 20,
+ vec![tag(b"ShpN")],
+ vec![
+ (tag(b"Shpe"), F::Def(25, vec![tag(b"ShNR")], vec![])),
+ (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 100.0, 100.0])),
+ (tag(b"BFFl"), F::Shared(vec![gradient])),
+ ],
+ );
+ let spread =
+ F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 200.0])), (tag(b"Chld"), F::Shared(vec![square]))]);
+ let doc = synth::stream(&[
+ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
+ (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
+ ]);
+ synth::container(&[("doc.dat", &doc, Method::Stored)], Some(&png()))
+ }
+
+ #[test]
+ fn conical_gradients_open_as_conic_gradients() {
+ use vectorcraft_color::{GradientKind, Paint};
+ for mirror in [false, true] {
+ let mut s = Session::new();
+ let r = open_bytes(&mut s, "conic.af", &conical_document(mirror), None).unwrap();
+ assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("conical")), "{r}");
+ let mut paint = None;
+ s.doc().unwrap().doc.walk(|n| {
+ if let Paint::Gradient(g) = n.appearance.fill_paint() {
+ paint = Some(g.clone());
+ }
+ });
+ let g = paint.expect("a gradient fill");
+ assert_eq!(g.gradient.kind, GradientKind::Conic);
+ let geom = g.geom.unwrap();
+ // At 144 ppi: centered at (25, 20) pt, 20 pt across, red where the sweep starts.
+ assert!(geom.start.distance(kurbo::Point::new(25.0, 20.0)) < 1e-9 && (geom.length() - 20.0).abs() < 1e-9, "{geom:?}");
+ assert_eq!(geom.is_mirrored(), mirror, "{geom:?}");
+ // A quarter turn on lies along the unit +y: below the center, or above it mirrored.
+ let quarter = kurbo::Point::new(25.0, if mirror { 10.0 } else { 30.0 });
+ assert!((geom.param_at(GradientKind::Conic, quarter) - 0.25).abs() < 1e-9, "{geom:?}");
+ assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
+ }
+ }
+
#[test]
fn layers_artboards_curves_and_paints_open_as_editable_art() {
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs
index c7926ddf84..9676c3b825 100644
--- a/crates/engine/src/cmd/gradient.rs
+++ b/crates/engine/src/cmd/gradient.rs
@@ -23,7 +23,7 @@ pub fn specs() -> Vec {
"Gradient",
["Window", "Gradient"],
None,
- "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (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 or radial, 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), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial 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|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; 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 or conic, 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 and conic: the vector turns about the center), aspect?: % (radial and conic; 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 or conic 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
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec {
"Gradient Vector",
[],
None,
- "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial: the extent ellipse's height / width; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
+ "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial and conic: the extent ellipse's height / width, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
has_doc,
set_gradient_geom
),
@@ -118,7 +118,7 @@ type Parsed = std::result::Result;
/// Parse a kind name.
fn parse_kind(k: &str) -> Parsed {
- GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
+ GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, freeform)"))
}
/// The `method` param of `p`, if given: how the colours between the stops mix.
@@ -274,9 +274,16 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed {
gp.angle = f64_or(g, "angle", 0.0);
gp.swatch = str_param(g, "swatch").map(str::to_string);
let geom = GeomEdit::parse(g)?;
+ let aspect = geom.aspect.map(|a| aspect_for(kind, a)).transpose()?;
+ if kind == GradientKind::Conic {
+ no_focal(&geom)?;
+ }
if let Some((start, end)) = geom.vector {
- let mut g = GradientGeom { start, end, aspect: geom.aspect.unwrap_or(1.0), focal: None };
- g.set_focal(geom.focal.flatten());
+ let mut g = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0), focal: None };
+ // Only a radial gradient has a focal point (as `paint.setGradientGeom` keeps it).
+ if kind == GradientKind::Radial {
+ g.set_focal(geom.focal.flatten());
+ }
gp.angle = g.angle_deg();
gp.geom = Some(g);
}
@@ -286,13 +293,36 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed {
Ok(gp)
}
-/// The `aspect` param (a percentage) as a ratio.
+/// The `aspect` param (a percentage) as a ratio of 0.005 to 327.67, its sign kept: a negative one
+/// mirrors a conic gradient's sweep ([`aspect_for`]).
fn aspect_param(p: &Value) -> Parsed> {
p.get("aspect")
- .map(|v| v.as_f64().map(|a| (a / 100.0).clamp(0.005, 327.67)).ok_or_else(|| "`aspect` must be a number (%)".to_string()))
+ .map(|v| {
+ v.as_f64()
+ .filter(|a| a.is_finite())
+ .map(|a| (a.abs() / 100.0).clamp(0.005, 327.67).copysign(a))
+ .ok_or_else(|| "`aspect` must be a number (%)".to_string())
+ })
.transpose()
}
+/// Aspect ratio `a` ([`aspect_param`]) for a gradient of `kind`: a negative one mirrors a conic's
+/// sweep, and is an error for the other kinds.
+fn aspect_for(kind: GradientKind, a: f64) -> Parsed {
+ if a < 0.0 && kind != GradientKind::Conic {
+ return Err("a negative `aspect` applies to conic gradients".into());
+ }
+ Ok(a)
+}
+
+/// An error when `e` gives a focal point: a conic gradient has none (`null` is allowed).
+fn no_focal(e: &GeomEdit) -> Parsed<()> {
+ match e.focal {
+ Some(Some(_)) => Err("`focal` applies to radial gradients".into()),
+ _ => Ok(()),
+ }
+}
+
/// `paint` without the placement it had on the art it came from (a swatch, or the default paint
/// for new art): a placed gradient keeps its angle and fits each object it lands on (freeform
/// points are placed afresh, coloured along the stops, which follow the points' colours).
@@ -400,7 +430,7 @@ fn apply_gradient_edit_in_model(
if let Some(g) = &mut gp.geom {
let r = a.to_radians();
let dir = vectorcraft_geom::Vec2::new(r.cos(), -r.sin());
- if gp.gradient.kind == GradientKind::Radial {
+ if gp.gradient.kind.is_centered() {
g.end = g.start + dir * g.length();
} else {
let c = g.start.midpoint(g.end);
@@ -411,6 +441,7 @@ fn apply_gradient_edit_in_model(
}
}
if let Some(asp) = aspect_param(p)? {
+ let asp = aspect_for(gp.gradient.kind, asp)?;
if gp.geom.is_none()
&& let Some(b) = bounds
{
@@ -472,7 +503,20 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result {
let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?;
// Validate against the defaults first so bad params fail without touching the document.
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
- let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(p, &default_paint, &stored), None, &|_| true, model)
+ // A negative aspect for selected conic gradients gives a default of another kind its size.
+ let default_kind = str_param(p, "kind").and_then(GradientKind::parse).or(match &default_paint {
+ Paint::Gradient(g) => Some(g.gradient.kind),
+ _ => None,
+ });
+ let for_default = match p.get("aspect").and_then(Value::as_f64) {
+ Some(a) if a < 0.0 && !ids.is_empty() && default_kind != Some(GradientKind::Conic) => {
+ let mut q = p.clone();
+ q["aspect"] = json!(-a);
+ std::borrow::Cow::Owned(q)
+ }
+ _ => std::borrow::Cow::Borrowed(p),
+ };
+ 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);
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
@@ -599,11 +643,23 @@ impl GeomEdit {
/// `cur` with the vector, aspect and focal point of `e` (given in the document). `to_doc` maps
/// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits
-/// an unplaced gradient, so what the document shows carries over where `e` keeps it.
-fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option, model: Option) -> Option {
- let from_doc = invert(to_doc)?;
+/// an unplaced gradient, so what the document shows carries over where `e` keeps it. An error
+/// when `e` gives what the gradient's kind can't take (a negative aspect other than a conic's, or
+/// a conic's focal point).
+fn vector_paint(
+ cur: &Paint,
+ e: &GeomEdit,
+ to_doc: Affine,
+ bounds: Option,
+ model: Option,
+) -> Parsed> {
let mut gp = gradient_for_edit(cur, model);
let kind = gp.gradient.kind;
+ let edit_aspect = e.aspect.map(|a| aspect_for(kind, a)).transpose()?;
+ if kind == GradientKind::Conic {
+ no_focal(e)?;
+ }
+ let Some(from_doc) = invert(to_doc) else { return Ok(None) };
let map = |mut g: GradientGeom, a: Affine| {
if a != Affine::IDENTITY {
g.transform(a, kind);
@@ -611,8 +667,8 @@ fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option,
g
};
let shown = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map(|g| map(g, to_doc));
- let (start, end) = e.vector.or_else(|| shown.map(|g| (g.start, g.end)))?;
- let aspect = e.aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0);
+ let Some((start, end)) = e.vector.or_else(|| shown.map(|g| (g.start, g.end))) else { return Ok(None) };
+ let aspect = edit_aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0);
let mut geom = GradientGeom { start, end, aspect, focal: None };
match (e.focal, shown) {
_ if kind != GradientKind::Radial => {}
@@ -623,7 +679,7 @@ fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option,
let geom = map(geom, from_doc);
gp.angle = geom.angle_deg();
gp.geom = Some(geom);
- Some(Paint::Gradient(Box::new(gp)))
+ Ok(Some(Paint::Gradient(Box::new(gp))))
}
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result {
@@ -658,14 +714,15 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result {
run_stroke_weight(&mut r.style);
}
let (paint, b) = run_paint_mut(r, stroke, lb);
- if let Some(np) = vector_paint(paint, &edit, xf, Some(b), model) {
+ if let Some(np) = vector_paint(paint, &edit, xf, Some(b), model).map_err(|e| bad(C, e))? {
*paint = np;
}
}
return Ok(());
}
let b = item_paint_bounds(n, index, !stroke);
- if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), &edit, Affine::IDENTITY, b, model) {
+ let cur = n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None);
+ if let Some(np) = vector_paint(cur, &edit, Affine::IDENTITY, b, model).map_err(|e| bad(C, e))? {
n.appearance.set_paint_at(index, !stroke, np);
}
Ok(())
diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs
index b68378ba9d..93c4cbf2c7 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|freeform, 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; 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), dither?: bool (linear and radial 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 or radial `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|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector), 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 and conic; 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), dither?: bool (linear, radial and conic 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 or conic `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)
),
@@ -641,7 +641,7 @@ fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) {
}
}
-/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets
+/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial or conic gradient the targets
/// would each fit to their own bounds can span them: one with its own vector (`start`/`end`),
/// a freeform one or a gradient swatch already has its placement.
fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result {
@@ -656,7 +656,7 @@ fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result
return Err("span: \"selection\" places a `gradient`, not a swatch".into());
}
if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() {
- return Err("span: \"selection\" places a linear or radial gradient, not a freeform one".into());
+ return Err("span: \"selection\" places a linear, radial or conic gradient, not a freeform one".into());
}
if g.geom.is_some() {
return Err(
diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs
index 2421441b91..1cd9849990 100644
--- a/crates/engine/src/cmd/swatch.rs
+++ b/crates/engine/src/cmd/swatch.rs
@@ -347,6 +347,7 @@ fn gradient_key(g: &Gradient) -> Option> {
GradientKind::Linear => 0,
GradientKind::Radial => 1,
GradientKind::Freeform => 2,
+ GradientKind::Conic => 3,
};
let method = match g.method {
GradientMethod::Classic => 0,
diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs
index e6f9383fe3..a3f7a2ae6e 100644
--- a/crates/engine/src/tests/gradient.rs
+++ b/crates/engine/src/tests/gradient.rs
@@ -32,7 +32,7 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
#[test]
fn new_gradient_defaults_follow_the_cmyk_document_mode() {
- for kind in ["linear", "radial", "freeform"] {
+ for kind in ["linear", "radial", "conic", "freeform"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width":200,"height":200,"colorMode":"cmyk"})).unwrap();
let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
@@ -123,7 +123,7 @@ fn bad_gradients_are_rejected_without_editing() {
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"color": "#000000"}]}),
json!({"stops": [{"offset": "a", "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}),
json!({"stops": "red"}),
- json!({"kind": "conic"}),
+ json!({"kind": "spiral"}),
json!({"start": [0, 0]}),
json!({"aspect": "wide"}),
] {
@@ -565,3 +565,185 @@ fn method_is_a_gradient_option_that_saves_and_loads() {
let back = fill_gradient(&s, id);
assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual);
}
+
+/// A conic gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params
+/// agents and swatches read, and the native format.
+#[test]
+fn a_conic_gradient_is_set_edited_transformed_and_saved() {
+ let mut s = session();
+ let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "conic"}})).unwrap();
+ let g = fill_gradient(&s, id);
+ assert_eq!(g.gradient.kind, GradientKind::Conic);
+ // Unplaced, it sweeps from the right about the box's center.
+ let geom = g.resolve(node(&s, id).geometric_bounds().unwrap());
+ assert_eq!(geom.start, Point::new(200.0, 150.0));
+ assert!((geom.param_at(GradientKind::Conic, Point::new(200.0, 190.0)) - 0.25).abs() < 1e-9);
+ // `angle` and `conical` name it too.
+ for name in ["angle", "conical", "CONICAL"] {
+ s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
+ s.execute("paint.editGradient", &json!({"kind": name})).unwrap();
+ assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Conic, "{name}");
+ }
+ let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string();
+ assert!(e.contains("(linear, radial, conic, freeform)"), "{e}");
+ // Placed, an angle turns the vector about the center.
+ s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap();
+ s.execute("paint.editGradient", &json!({"angle": 90})).unwrap();
+ let g = fill_geom(&s, id);
+ assert!(g.start.distance(Point::new(200.0, 150.0)) < 1e-9 && g.end.distance(Point::new(200.0, 90.0)) < 1e-9, "{g:?}");
+ // A reflection mirrors the sweep (a negative aspect): each point keeps its color.
+ let p = Point::new(230.0, 170.0);
+ let before = g.param_at(GradientKind::Conic, p);
+ s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
+ let m = fill_geom(&s, id);
+ assert!(m.is_mirrored(), "{m:?}");
+ assert!((m.param_at(GradientKind::Conic, Point::new(170.0, 170.0)) - before).abs() < 1e-9);
+ // An aspect edit sets what it is given: the Gradient panel sends a mirrored conic's sign.
+ s.execute("paint.editGradient", &json!({"aspect": -80})).unwrap();
+ assert_eq!(fill_geom(&s, id).aspect, -0.8);
+ s.execute("paint.setGradientGeom", &json!({"aspect": 40})).unwrap();
+ assert_eq!(fill_geom(&s, id).aspect, 0.4);
+ // A rotation carries it along.
+ let g = fill_geom(&s, id);
+ let before = g.param_at(GradientKind::Conic, p);
+ s.execute("object.rotate", &json!({"angle": 30, "origin": [200, 150]})).unwrap();
+ let r = vectorcraft_geom::Affine::rotate_about(-30f64.to_radians(), Point::new(200.0, 150.0));
+ let after = fill_geom(&s, id).param_at(GradientKind::Conic, r * p);
+ assert!((after - before).abs() < 1e-9, "{before} vs {after}");
+ // The params give it back, and the native format keeps it.
+ s.execute("paint.editGradient", &json!({"aspect": -60})).unwrap();
+ let want = fill_gradient(&s, id);
+ let params = vectorcraft_tools::params::gradient_params(&want);
+ assert_eq!((params["kind"].as_str(), params["aspect"].as_f64()), (Some("conic"), Some(-60.0)));
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
+ s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
+ let got = fill_gradient(&s, id);
+ assert_eq!((&got.gradient, got.geom), (&want.gradient, want.geom));
+ let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
+ let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") };
+ assert_eq!((g.gradient.kind, g.geom), (GradientKind::Conic, want.geom));
+}
+
+/// A free distort that is not affine moves a conic as it moves a radial gradient: by the warp
+/// near its center, not near the middle of its vector.
+#[test]
+fn a_warp_moves_a_conic_by_its_center() {
+ let warped = |kind: &str| {
+ let mut s = session();
+ let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [20, 30], "end": [90, 30], "aspect": 60}})).unwrap();
+ s.execute("object.distort", &json!({"corners": [[0, 0], [100, 0], [140, 130], [-20, 100]]})).unwrap();
+ fill_geom(&s, id)
+ };
+ let (conic, radial, linear) = (warped("conic"), warped("radial"), warped("linear"));
+ assert!(conic.start.distance(radial.start) < 1e-9, "{conic:?} vs {radial:?}");
+ assert!(conic.start.distance(linear.start) > 0.1, "a linear gradient moves by its middle: {linear:?}");
+}
+
+/// A negative aspect mirrors a conic's sweep where the gradient is given one, and a mirrored
+/// conic swatch lands mirrored on other art. Other kinds refuse a negative aspect, and a conic
+/// refuses a focal point.
+#[test]
+fn a_negative_aspect_mirrors_only_a_conic() {
+ let mut s = session();
+ let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "aspect": -50}})).unwrap();
+ assert_eq!(fill_gradient(&s, id).geom.map(|g| g.aspect), Some(-0.5));
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [0, 0], "end": [10, 0], "aspect": -200}})).unwrap();
+ assert_eq!(fill_gradient(&s, id).geom.map(|g| g.aspect), Some(-2.0));
+ let mut q = gradient_param(&fill_gradient(&s, id));
+ q["name"] = json!("Mirrored");
+ s.execute("swatch.new", &q).unwrap();
+ let b = rect(&mut s, 500.0, 300.0, 100.0, 100.0);
+ s.execute("paint.setFill", &json!({"swatch": "Mirrored"})).unwrap();
+ assert_eq!(fill_gradient(&s, b).geom.map(|g| g.aspect), Some(-2.0));
+ // Radial (and linear) gradients have no mirror: a negative aspect is an error, whichever
+ // command gives it, and the gradient stays as it was.
+ let before = fill_gradient(&s, b);
+ for kind in ["radial", "linear"] {
+ let e = s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "aspect": -50}})).unwrap_err().to_string();
+ assert!(e.contains("aspect"), "{kind}: {e}");
+ let e = s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [0, 0], "end": [10, 0], "aspect": -50}})).unwrap_err();
+ assert!(e.to_string().contains("aspect"), "{kind}: {e}");
+ }
+ assert_eq!(fill_gradient(&s, b), before);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "aspect": 50}})).unwrap();
+ let radial = fill_gradient(&s, b);
+ assert!(s.execute("paint.editGradient", &json!({"aspect": -50})).is_err());
+ assert!(s.execute("paint.setGradientGeom", &json!({"aspect": -50})).is_err());
+ assert!(s.execute("paint.editGradient", &json!({"kind": "linear", "aspect": -50})).is_err());
+ assert_eq!(fill_gradient(&s, b), radial);
+ // Turned conic in the same edit, a negative aspect mirrors it.
+ s.execute("paint.editGradient", &json!({"kind": "conic", "aspect": -50})).unwrap();
+ assert_eq!(fill_gradient(&s, b).geom.map(|g| g.aspect), Some(-0.5));
+ // A conic has no focal point: one given is an error, and null is allowed.
+ let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [500, 300], "end": [550, 300], "focal": [510, 300]}}));
+ assert!(e.unwrap_err().to_string().contains("focal"));
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [500, 300], "end": [550, 300], "focal": null}})).unwrap();
+ assert_eq!(fill_gradient(&s, b).geom.unwrap().focal, None);
+ assert!(s.execute("paint.setGradientGeom", &json!({"focal": [510, 300]})).unwrap_err().to_string().contains("focal"));
+ // A selected conic takes a negative aspect while the default paint is linear: the default
+ // takes its size.
+ s.execute("select.none", &json!({})).unwrap();
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
+ s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
+ s.execute("paint.editGradient", &json!({"kind": "conic"})).unwrap();
+ s.execute("select.none", &json!({})).unwrap();
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
+ s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
+ s.execute("paint.editGradient", &json!({"aspect": -70})).unwrap();
+ assert_eq!(fill_gradient(&s, b).geom.map(|g| g.aspect), Some(-0.7));
+ // A linear gradient keeps no focal point, as paint.setGradientGeom leaves it.
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "start": [500, 300], "end": [550, 300], "focal": [510, 300]}})).unwrap();
+ assert_eq!(fill_gradient(&s, b).geom.unwrap().focal, None);
+}
+
+/// Saved as `.ai`, as PDF with its editing data and as SVG with its editing data, then opened, a
+/// mirrored, squashed conic comes back as it was; a PDF without editing data gives it back from
+/// its sweep shading.
+#[test]
+fn a_conic_survives_saving_as_ai_pdf_and_svg() {
+ let mut s = session();
+ let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [180, 140], "end": [240, 110], "aspect": -60}})).unwrap();
+ let want = fill_gradient(&s, id);
+ let reopened = |s: &mut Session, format: &str, p: Value| {
+ let mut p = p;
+ p["format"] = json!(format);
+ let r = s.execute("file.saveCopy", &p).unwrap();
+ let mut back = session();
+ back.execute("document.open", &json!({"name": format!("t.{format}"), "dataBase64": r["dataBase64"]})).unwrap();
+ let mut found = None;
+ back.doc().unwrap().doc.walk(|n| {
+ if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) {
+ found = Some(*g.clone());
+ }
+ });
+ found.unwrap_or_else(|| panic!("{format}: the gradient is lost"))
+ };
+ for (format, p) in [("ai", json!({})), ("pdf", json!({"preserveEditing": true})), ("svg", json!({"preserveEditing": true}))] {
+ let got = reopened(&mut s, format, p);
+ assert_eq!((got.gradient.kind, got.geom), (GradientKind::Conic, want.geom), "{format}");
+ }
+ let got = reopened(&mut s, "pdf", json!({"preserveEditing": false}));
+ assert_eq!(got.gradient.kind, GradientKind::Conic);
+ let (g, w) = (got.geom.unwrap(), want.geom.unwrap());
+ assert!(g.is_mirrored() && g.start.distance(w.start) < 0.05, "{g:?}");
+ for p in [Point::new(120.0, 120.0), Point::new(280.0, 180.0), Point::new(200.0, 105.0)] {
+ assert!((g.param_at(GradientKind::Conic, p) - w.param_at(GradientKind::Conic, p)).abs() < 2e-3, "{p:?}: {g:?}");
+ }
+}
+
+/// Turning a gradient conic is one undo step, and undoing it gives the gradient back as it was.
+#[test]
+fn making_a_gradient_conic_is_one_undo_step() {
+ let mut s = session();
+ let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
+ let (before, steps) = (fill_gradient(&s, id), s.doc().unwrap().history.undo.len());
+ s.execute("paint.editGradient", &json!({"kind": "conic", "angle": 45})).unwrap();
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(fill_gradient(&s, id), before);
+}
diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs
index 2930bf1721..f638355c52 100644
--- a/crates/engine/src/tests/objexpand.rs
+++ b/crates/engine/src/tests/objexpand.rs
@@ -321,6 +321,78 @@ fn a_radial_gradient_becomes_concentric_ellipses() {
assert!(f.opacity < 1.0 && f.opacity > 0.5, "the centre band is translucent: {}", f.opacity);
}
+/// Three opaque stops sweeping once around the center.
+fn conic_stops() -> Value {
+ json!({"kind": "conic", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]})
+}
+
+#[test]
+fn a_conic_gradient_becomes_wedges_or_a_mesh_that_paint_it() {
+ // Objects: a wedge per step from the center, together painting what the canvas painted.
+ let mut s = session();
+ gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), conic_stops(), false);
+ let before = render(&s);
+ run(&mut s, "object.expand", json!({"steps": 180}));
+ let g = selected(&s);
+ let (_, wedges) = clip_parts(&g);
+ assert_eq!(wedges.len(), 180);
+ let first = wedges[0].path_data().unwrap().subpaths[0].anchors[0].p;
+ assert!(first.distance(vectorcraft_geom::Point::new(100.0, 100.0)) < 1e-9, "wedges start at the center: {first:?}");
+ let after = render(&s);
+ assert!(differing(&before, &after, 48) < 200, "{}", differing(&before, &after, 48));
+ assert!(mean_diff(&before, &after) < 2.0, "{}", mean_diff(&before, &after));
+ // Translucent stops: each wedge is just its share of the turn, ending where the next starts,
+ // so none doubles up (only the anti-aliased seams between them differ from the canvas).
+ let mut s = session();
+ let gr = json!({"kind": "conic", "stops": [{"offset": 0, "color": "#ff0000", "opacity": 0.5}, {"offset": 1, "color": "#0000ff"}]});
+ 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": 36}));
+ let g = selected(&s);
+ let (_, wedges) = clip_parts(&g);
+ let outline = |n: &Node| n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect::>();
+ for pair in wedges.windows(2) {
+ let (a, b) = (outline(pair[0]), outline(pair[1]));
+ assert!(a.last().unwrap().distance(b[1]) < 1e-6, "{a:?} then {b:?}");
+ }
+ assert!(mean_diff(&before, &render(&s)) < 4.0, "{}", mean_diff(&before, &render(&s)));
+ // Mesh: columns around the center.
+ let mut s = session();
+ gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), conic_stops(), false);
+ let before = render(&s);
+ run(&mut s, "object.expand", json!({"gradient": "mesh"}));
+ let g = selected(&s);
+ let (_, content) = clip_parts(&g);
+ let NodeKind::Mesh(m) = &content[0].kind else { panic!("a mesh node: {:?}", content[0].kind) };
+ assert!(m.is_valid());
+ let after = render(&s);
+ assert!(differing(&before, &after, 48) < 200, "{}", differing(&before, &after, 48));
+ assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after));
+}
+
+/// A mirrored conic, and one whose extent ellipse is flat (as a file can hold it), expand as the
+/// canvas draws them.
+#[test]
+fn a_mirrored_or_flat_conic_expands_as_the_canvas_draws_it() {
+ for aspect in [-0.6, 0.0] {
+ for how in [json!({"steps": 180}), json!({"gradient": "mesh"})] {
+ let mut s = session();
+ let id = gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), conic_stops(), false);
+ let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
+ let n = d.node_mut(id).unwrap();
+ let Paint::Gradient(mut g) = n.appearance.fill_paint().clone() else { panic!("a gradient fill") };
+ let (start, end) = (vectorcraft_geom::Point::new(90.0, 110.0), vectorcraft_geom::Point::new(130.0, 80.0));
+ g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect, focal: None });
+ n.appearance.set_fill(Paint::Gradient(g));
+ let before = render(&s);
+ run(&mut s, "object.expand", how.clone());
+ let after = render(&s);
+ assert!(differing(&before, &after, 48) < 200, "{aspect} {how}: {}", differing(&before, &after, 48));
+ assert!(mean_diff(&before, &after) < 2.5, "{aspect} {how}: {}", mean_diff(&before, &after));
+ }
+ }
+}
+
#[test]
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
for gradient in [
diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs
index 7e7204f6e1..28f81587ab 100644
--- a/crates/engine/src/tests/panelcmds.rs
+++ b/crates/engine/src/tests/panelcmds.rs
@@ -64,7 +64,7 @@ fn edit_gradient_angle_reverse_and_errors() {
assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff");
assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops);
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
- assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
+ assert!(s.execute("paint.editGradient", &json!({"kind": "spiral"})).is_err());
}
#[test]
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index 67ca443a7b..a99646c9c2 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -7536,3 +7536,42 @@ fn variable_font_instances_name_their_design_space() {
fn style_of(size: f64) -> vectorcraft_doc::CharStyle {
style(size, Color::BLACK)
}
+
+/// The data has no conic gradient: a conic fill is a linear gradient along its vector, with the
+/// same stops, and so is a conic gradient swatch, with a warning.
+#[test]
+fn conic_gradients_are_written_as_linear_gradients_with_their_stops() {
+ let stops =
+ vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(0.6, SKY), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Conic, stops.clone()));
+ let (start, end) = (vectorcraft_geom::Point::new(100.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0));
+ g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: -0.5, 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.clone(), Paint::None, 0.0)))
+ .unwrap();
+ let mut swatches: Vec = doc.swatches.iter().cloned().collect();
+ swatches.push(Swatch { name: "Sweep".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("conic gradients are written as linear gradients")), "{:?}", out.warnings);
+ let back = read_back(&doc);
+ let mut read = None;
+ back.walk(|n| {
+ if let Paint::Gradient(g) = n.appearance.fill_paint() {
+ read = Some(g.clone());
+ }
+ });
+ let read = read.expect("a gradient fill");
+ assert_eq!(read.gradient.kind, GradientKind::Linear);
+ assert_eq!(
+ read.gradient.stops.iter().map(|s| (s.offset, s.color.to_hex())).collect::>(),
+ stops.iter().map(|s| (s.offset, s.color.to_hex())).collect::>()
+ );
+ let geom = read.geom.unwrap();
+ assert!(geom.start.distance(start) < 0.01 && geom.end.distance(end) < 0.01, "{geom:?}");
+ let Some(Paint::Gradient(swatch)) = back.swatch("Sweep").map(|s| &s.paint) else { panic!("the swatch is lost") };
+ assert_eq!(swatch.gradient.kind, GradientKind::Linear);
+}
diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs
index b9d28253d0..892a0d1b65 100644
--- a/crates/eps/src/ai_editing/writer/paints.rs
+++ b/crates/eps/src/ai_editing/writer/paints.rs
@@ -21,6 +21,8 @@ pub(in crate::ai_editing) const SWATCH_PLACEMENT: &str =
"the placement of pattern swatches that move, turn or scale their pattern is left out of the editing data for other apps";
pub(in crate::ai_editing) const GRADIENT_MIX: &str =
"gradients that mix their colors perceptually or dither are written as classic gradients in the editing data for other apps";
+pub(in crate::ai_editing) const CONIC: &str =
+ "conic gradients are written as linear gradients along their vector, with the same stops, in the editing data for other apps";
pub(in crate::ai_editing) const STOP_LINKS: &str =
"gradient stops linked to global process colors are written as their colors in the editing data for other apps";
/// The length of a linear gradient's caps (`Bc`): the canvas's side.
@@ -43,8 +45,8 @@ pub(super) struct PatternOut {
used: bool,
}
-/// Can `g` be written as a gradient (a linear or radial one with stops, no more than the `.ai`
-/// reader reads)?
+/// Can `g` be written as a gradient (a linear, radial or conic one with stops, no more than the
+/// `.ai` reader reads; a conic is written linear, [`CONIC`])?
fn writable_gradient(g: &Gradient) -> bool {
g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS
}
@@ -300,6 +302,9 @@ impl Writer<'_> {
if d.gradient.dither || !d.gradient.method.is_classic() {
self.warn(GRADIENT_MIX);
}
+ if d.gradient.kind == GradientKind::Conic {
+ self.warn(CONIC);
+ }
let name = ps::bytes(d.name.as_bytes());
let radial = d.gradient.kind == GradientKind::Radial;
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {} {} Bd\n[", u8::from(radial), d.gradient.stops.len());
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index 6b553b8d1a..68c7610308 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -107,6 +107,9 @@ pub const TITLE_AND_COLOR_NAMES: &str =
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
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)";
+/// Wedges a conic gradient is written as: one a degree.
+const CONIC_WEDGES: usize = 360;
const STROKES: &str =
"strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines";
const IMAGE_ALPHA: &str = "transparent image pixels are written over white";
diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs
index 8ea787f8a5..60eb9b0c1c 100644
--- a/crates/eps/src/legacy_ai/encoding.rs
+++ b/crates/eps/src/legacy_ai/encoding.rs
@@ -201,7 +201,7 @@ pub(super) fn segment_point(path: &BezPath, fraction: f64) -> f64 {
pub(super) fn direct_fill(p: &Paint) -> bool {
match p {
Paint::Solid { .. } => true,
- Paint::Gradient(g) => g.gradient.kind != GradientKind::Freeform && !g.gradient.stops.is_empty(),
+ Paint::Gradient(g) => !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic) && !g.gradient.stops.is_empty(),
_ => false,
}
}
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 979cd2a436..8917c9aa73 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -83,6 +83,33 @@ fn write(doc: &Document) -> LegacyAiOutput {
write_with(doc, LegacyAiOptions::default())
}
+/// The format has no conic gradient: a conic fill, and a conic stroke (written as its filled
+/// outline), are solid wedges clipped to the shape, with a warning, and print as the canvas
+/// draws them ([`write`] runs the file).
+#[test]
+fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() {
+ 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::new(GradientKind::Conic, 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 conic = Paint::Gradient(Box::new(g));
+ for (fill, stroke) in [(conic.clone(), Paint::None), (Paint::None, conic)] {
+ 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)), fill.clone(), stroke.clone());
+ let l = doc.layers[0].id;
+ doc.insert(Some(l), 0, n).unwrap();
+ let out = write(&doc);
+ assert!(out.warnings.iter().any(|w| w == CONIC), "{:?}", out.warnings);
+ let text = String::from_utf8_lossy(&out.bytes);
+ assert!(!text.contains("%AI5_BeginGradient"), "no gradient stands in");
+ assert!(text.matches("\nf\n").count() >= CONIC_WEDGES, "a wedge a degree");
+ }
+}
+
/// `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 {
@@ -155,7 +182,10 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
- if let Paint::Gradient(g) = paint {
+ // Conic gradients are written as wedges, which print.
+ if let Paint::Gradient(g) = paint
+ && g.gradient.kind != GradientKind::Conic
+ {
*paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
}
}
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index dc5128565d..068f534868 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -9,6 +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};
pub(super) struct Writer<'a> {
doc: &'a Document,
@@ -423,6 +424,11 @@ impl<'a> Writer<'a> {
if direct_fill(&f.paint) {
return self.path_object(path, rule, Some(f), None);
}
+ if let Paint::Gradient(g) = &f.paint
+ && g.gradient.kind == GradientKind::Conic
+ {
+ return self.conic_object(path, rule, f, g);
+ }
let Paint::Pattern { pattern, xf } = &f.paint else {
// A freeform gradient: its average colour.
self.warn(FREEFORM);
@@ -461,6 +467,36 @@ impl<'a> Writer<'a> {
self.line("Q");
}
+ /// A conic gradient fill (the format has no conic gradient) as [`CONIC_WEDGES`] solid wedges
+ /// about its center clipped to `path` (filled by `rule`), each running on to the seam under
+ /// the next ([`vectorcraft_color::GradientGeom::conic_wedges`]).
+ fn conic_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer, g: &GradientPaint) {
+ self.warn(CONIC);
+ if g.gradient.stops.iter().any(|s| s.opacity < 1.0) {
+ self.warn(TRANSPARENCY);
+ }
+ let area = path.to_bezpath().bounding_box();
+ let wedges = g.resolve(area).conic_wedges(area, CONIC_WEDGES, true);
+ self.line("q");
+ self.fill_rule(rule);
+ let several = path.subpaths.len() > 1;
+ if several {
+ self.line("*u");
+ }
+ for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) {
+ self.mask(sp);
+ }
+ if several {
+ self.line("*U");
+ }
+ for (pts, t) in wedges {
+ let (color, _) = g.gradient.sample_keep_model(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);
+ }
+ self.line("Q");
+ }
+
/// A path object painted with `fill` (a colour or a linear or radial gradient) and `stroke`
/// (a plain line of a colour) over it; several subpaths make a compound path.
fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) {
diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs
index 8c9b04ff80..3b2a91f0ff 100644
--- a/crates/eps/src/scene.rs
+++ b/crates/eps/src/scene.rs
@@ -42,6 +42,9 @@ const SPOT_GRADIENTS_L2: &str =
"gradients mixing a spot color with other inks are written in process colors at PostScript Level 2 (Level 3 keeps the spot inks)";
const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks";
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)";
+/// Wedges a conic gradient is written as: one a degree.
+const CONIC_WEDGE_COUNT: usize = 360;
/// 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";
@@ -931,8 +934,9 @@ impl<'a> Scene<'a> {
(space, out)
}
- /// Paint all of `area` (inside the current clip) with linear or radial gradient `g`, placed on
- /// `bounds`: a smooth shading at Level 3, else stepped fills.
+ /// Paint all of `area` (inside the current clip) with linear, radial or conic gradient `g`,
+ /// placed on `bounds`: a smooth shading at Level 3, else stepped fills (a conic: wedges at
+ /// both levels).
fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
let geom = g.resolve(bounds);
let (space, stops) = self.gradient_stops(&g.gradient);
@@ -946,6 +950,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::Radial => {
let r = geom.length().max(1e-6);
let squash = geom.radial_squash();
@@ -974,6 +979,26 @@ 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)]) {
+ self.warn(CONIC_WEDGES);
+ 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 (i, p) in pts.iter().enumerate() {
+ push_nums(&mut self.body, &[p.x, p.y]);
+ self.line(if i == 0 { "newpath moveto" } else { "lineto" });
+ }
+ self.line("closepath fill");
+ }
+ }
+
/// A Level 3 smooth shading of `kind` (2 axial, 3 radial) at `coords`, extended both ways.
fn shading(&mut self, kind: u8, space: &GradientSpace, coords: &[f64], stops: &[(f64, Vec)]) {
let n = space.n();
diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs
index f447d7b7d9..96cf75c02a 100644
--- a/crates/eps/src/tests.rs
+++ b/crates/eps/src/tests.rs
@@ -189,6 +189,34 @@ fn gradients_are_smooth_shadings_at_level_3_and_stepped_fills_at_level_2() {
assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("2"));
}
+/// PostScript has no conic shading: a conic is solid wedges about its center at both levels, each
+/// running on to the seam, and read back from the PostScript it looks as the canvas paints it.
+#[test]
+fn conic_gradients_are_solid_wedges_at_both_levels() {
+ 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))];
+ for aspect in [1.0, -0.5] {
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Conic, stops.clone()));
+ g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(100.0, 70.0), end: Point::new(150.0, 70.0), aspect, 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, 120.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();
+ assert!(out.warnings.iter().any(|w| w.contains("conic gradients are written as solid wedges")), "{:?}", out.warnings);
+ let page = page_of(&d, &o);
+ assert!(!page.contains("shfill"), "no shading stands in");
+ assert_eq!(page.matches("closepath fill").count(), 360, "a wedge a degree");
+ }
+ let out = export(&d, &opts(&d), None, None).unwrap();
+ let back = import_with(&out.bytes, false).unwrap().document;
+ vectorcraft_testkit::raster::assert_similar(
+ &vectorcraft_testkit::raster::render_artboard(&d),
+ &vectorcraft_testkit::raster::render_artboard(&back),
+ 8.0,
+ 0.01,
+ );
+ }
+}
+
#[test]
fn strokes_keep_their_caps_joins_and_dashes() {
let d = doc_with(|d| {
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index d8316e94e3..f4d65398e0 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -13,7 +13,7 @@ use krilla::geom::{Path, PathBuilder, Size};
use krilla::image::Image;
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
-use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
+use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash, SweepGradient};
use krilla::text::{Glyph, GlyphId, KrillaGlyph};
use kurbo::{PathEl, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
@@ -68,6 +68,9 @@ pub(crate) struct Exporter<'a> {
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
/// Images may ask viewers to smooth them (not in PDF/A).
interpolate: bool,
+ /// Conic gradients may be written as sweep shadings, whose PostScript calculator functions
+ /// PDF/A forbids; otherwise they are drawn as images ([`Self::area_paint`]).
+ sweeps: bool,
/// Images may have 16 bits a sample (PDF 1.5 and later); in earlier files they are written at
/// 8 bits ([`embed`]).
sixteen_bit: bool,
diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs
index d3d5f8e7fe..ff94c6759f 100644
--- a/crates/pdf/src/export/exporter.rs
+++ b/crates/pdf/src/export/exporter.rs
@@ -35,6 +35,7 @@ impl<'a> Exporter<'a> {
knockout: doc.page_knockout,
lab_spots: vec![],
interpolate: set.standard != Standard::PdfA2b,
+ sweeps: set.standard != Standard::PdfA2b,
// PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]).
sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14),
compression: &set.compression,
diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs
index fde13f4d3c..760021b25a 100644
--- a/crates/pdf/src/export/nodes.rs
+++ b/crates/pdf/src/export/nodes.rs
@@ -1,7 +1,7 @@
use super::exporter::{UNDECODED, address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf};
use super::{
- Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, Paint, Path, Rect,
- Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
+ Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32,
+ Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
};
/// An object's visual box for leaving out what is off the page. A plain group or layer also holds
@@ -433,11 +433,15 @@ impl Exporter<'_> {
/// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which
/// [`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`])?
+ /// inks ([`Self::perceptual_spot`]), or a conic gradient that can't be a sweep shading (in
+ /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops)?
pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool {
match p {
Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(),
- Paint::Gradient(g) => g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill),
+ Paint::Gradient(g) => {
+ let sweep_unwritable = || !self.sweeps || g.gradient.expanded().nth(super::paint::MAX_SWEEP_STOPS).is_some();
+ (g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill)) || (g.gradient.kind == GradientKind::Conic && sweep_unwritable())
+ }
_ => false,
}
}
diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs
index 9e4578bdca..15f3f46637 100644
--- a/crates/pdf/src/export/paint.rs
+++ b/crates/pdf/src/export/paint.rs
@@ -1,9 +1,14 @@
use super::exporter::{MAX_FIELD_PX, MAX_GRADIENT_PX, MAX_PATTERN_DEPTH, color, ctm, in_range, norm, q, to_path, writable, xf};
use super::{
Affine, Color, Exporter, GradientKind, LinearGradient, Paint, RadialGradient, Rect, SepColor, SeparationColorant, SeparationSpace, Shape, Size,
- SpreadMethod, Stop, Surface, Vec2, cmyk, krilla, rgb,
+ SpreadMethod, Stop, Surface, SweepGradient, Vec2, cmyk, krilla, rgb,
};
+/// Most stops a conic gradient's sweep shading holds: past this the PDF writer keeps only some
+/// of them (its calculator function tests the stops one inside another, and viewers limit how
+/// deep), so such a gradient is drawn as an image instead ([`Exporter::area_paint`]).
+pub(super) const MAX_SWEEP_STOPS: usize = 97;
+
impl Exporter<'_> {
/// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without
/// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the
@@ -311,6 +316,28 @@ impl Exporter<'_> {
.into(),
)
}
+ // 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.
+ GradientKind::Conic => {
+ let frame = geom.conic_frame();
+ if !writable(s, space * frame, Rect::new(-1.0, -1.0, 1.0, 1.0)) {
+ return None;
+ }
+ Some(
+ SweepGradient {
+ cx: 0.0,
+ cy: 0.0,
+ start_angle: 0.0,
+ end_angle: 360.0,
+ transform: xf(space * frame),
+ spread_method: SpreadMethod::Pad,
+ stops,
+ anti_alias: false,
+ }
+ .into(),
+ )
+ }
// Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient
// slices) the average colour stands in.
GradientKind::Freeform => {
@@ -452,7 +479,7 @@ impl Exporter<'_> {
/// 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
- /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side),
+ /// [`MAX_FIELD_PX`] a side), or a linear, radial or conic gradient (at most [`MAX_GRADIENT_PX`] a side),
/// with dither's noise when it paints a fill (`fill`). A linear or radial gradient whose stops
/// are all CMYK and not linked to a spot swatch is a CMYK image, unless its Perceptual curve
/// has too many stops; any other is an RGB image.
@@ -476,7 +503,9 @@ impl Exporter<'_> {
let (cols, rows) = (along(w), along(h));
let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b)));
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
- if !freeform {
+ if g.gradient.kind == GradientKind::Conic {
+ self.warn("conic gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images");
+ } else if !freeform {
self.warn("linear and radial gradients drawn as pixels are exported as images");
}
let ramp_inks = if freeform || self.has_spot_stop(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) };
diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs
index c80b2a29b4..ef91bdad42 100644
--- a/crates/pdf/src/import_shading.rs
+++ b/crates/pdf/src/import_shading.rs
@@ -1,7 +1,8 @@
//! 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-based one whose colour changes along one axis a linear gradient (else its average
-//! colour; all of them within one budget of function evaluations per import), patch and triangle
+//! function-based one whose color changes along one axis a linear gradient, and one whose color
+//! turns around the origin of its space a conic gradient (else its average color; all of them
+//! within one budget of function evaluations per import), patch and triangle
//! meshes become gradient meshes, a gradient drawn through a luminosity mask that is
//! the same gradient in grey (how stop opacity is written) gets its opacity stops back, and a shape
//! clipping a gradient that fills it becomes one path with the gradient.
@@ -159,7 +160,7 @@ impl FunctionBudget {
/// What a function-based shading comes in as ([`function_paint`]).
pub(crate) enum FunctionPaint {
- /// Its colour changes along one axis: a linear gradient.
+ /// Its color changes along one axis (a linear gradient) or around the origin (a conic one).
Gradient(Box),
/// It has no gradient: the colour it averages to.
Average(Color),
@@ -179,7 +180,8 @@ pub(crate) fn function_paint(
if !matches!(sp.shading.shading_type.as_ref(), ShadingType::FunctionBased { .. }) {
return None;
}
- Some(match function_gradient(sp, colors, inks, budget) {
+ let gradient = function_gradient(sp, colors, inks, budget).or_else(|| function_sweep(sp, colors, inks, budget));
+ Some(match gradient {
Some(g) => FunctionPaint::Gradient(Box::new(g)),
None => function_average(sp, budget).map_or(FunctionPaint::Unread, FunctionPaint::Average),
})
@@ -257,6 +259,162 @@ fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<
_ => return None,
};
let point = |s: f32| if along_x { (lerp(x0, x1, s), lerp(y0, y1, 0.5)) } else { (lerp(x0, x1, 0.5), lerp(y0, y1, s)) };
+ let out = function_stops(sp, function, colors, inks, budget, &point)?;
+ let p = |s: f32| {
+ let (x, y) = point(s);
+ Point::new(x as f64, y as f64)
+ };
+ let mut geom = GradientGeom { start: p(0.0), end: p(1.0), aspect: 1.0, focal: None };
+ geom.transform(sp.matrix * *matrix, GradientKind::Linear);
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, out));
+ g.geom = Some(geom);
+ // It paints only over its domain ([`extend_clip`]).
+ Some(g)
+}
+
+/// A function-based shading (type 1) whose color turns around the origin of its space and stays
+/// the same along each ray from it (how a conic gradient is written: a function of the angle,
+/// ISO 32000-1 8.7.4.5.2, as the calculator function's `atan` gives it, 7.10.5) as a conic
+/// gradient, its vector along the space's x axis and its sweep towards its y axis: stops sampled
+/// around a circle about the origin, as [`function_stops`] samples them. When the origin lies
+/// outside the domain (a conic whose center is off the painted area), the stops are read across
+/// the turns the domain shows, and the colors there extend to the rest of the sweep. `None` when
+/// the color changes along a ray or doesn't change from ray to ray, the stops would be more than
+/// [`MAX_FUNCTION_STOPS`], or `budget` runs out (leaving [`AVERAGE_EVALUATIONS`] for
+/// [`function_average`]).
+fn function_sweep(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>, budget: &FunctionBudget) -> Option {
+ use std::f32::consts::TAU;
+ let ShadingType::FunctionBased { matrix, .. } = sp.shading.shading_type.as_ref() else { return None };
+ let (function, domain) = function_domain(sp)?;
+ let [x0, x1, y0, y1] = domain;
+ let rgb = |x: f32, y: f32| function_rgb(sp, function, x, y, budget, AVERAGE_EVALUATIONS);
+ let around = x0 < 0.0 && x1 > 0.0 && y0 < 0.0 && y1 > 0.0;
+ // The angles the domain shows: once around, ending just before the seam (where the angle
+ // starts again at 0), or those its corners span seen from outside it.
+ let (a0, a1) = if around { (0.0, TAU * (1.0 - SWEEP_SEAM)) } else { seen_angles(domain)? };
+ // Around the origin, the circle the stops are read on, well inside the domain.
+ let inner = 0.25 * (-x0).min(x1).min(-y0).min(y1);
+ // Where the ray at angle `a` runs through the domain (from that circle around the origin).
+ let ray = |a: f32| ray_in_domain(a, domain).map(|(near, far)| if around { (inner, far) } else { (near, far) });
+ // Along each test ray (off round angles), the color near where it enters the domain, halfway
+ // through and near where it leaves is the same. Outside, the first is a little way in, off the
+ // origin when the domain's edge runs through it.
+ let mut first = None;
+ let mut turns = false;
+ let enter = if around { 0.0 } else { 0.05 };
+ for i in 0..=FUNCTION_GRID {
+ let a = a0 + (a1 - a0) * grid_at(i);
+ let (dx, dy) = (a.cos(), a.sin());
+ let (near, far) = ray(a)?;
+ let at = |f: f32| near + (far - near) * f;
+ let c = rgb(at(enter) * dx, at(enter) * dy)?;
+ for r in [at(0.5), at(0.9)] {
+ if !same_rgb(rgb(r * dx, r * dy)?, c) {
+ return None;
+ }
+ }
+ match first {
+ None => first = Some(c),
+ Some(f) => turns |= !same_rgb(f, c),
+ }
+ }
+ if !turns {
+ return None;
+ }
+ // The stops of turns `from..to` (as fractions of a turn from the space's x axis; `angle` gives
+ // the angle of a turn), read where each ray enters the domain, or halfway through it outside.
+ let mut read = |from: f32, to: f32, angle: &dyn Fn(f32) -> f32| -> Option> {
+ let point = |s: f32| {
+ let a = angle(from + (to - from) * s);
+ let (near, far) = ray(a).unwrap_or((0.0, 0.0));
+ let r = if around { near } else { (near + far) / 2.0 };
+ (r * a.cos(), r * a.sin())
+ };
+ let mut stops = function_stops(sp, function, colors, inks, budget, &point)?;
+ for s in &mut stops {
+ s.offset = from + (to - from) * s.offset;
+ }
+ Some(stops)
+ };
+ let out = if around {
+ read(0.0, 1.0 - SWEEP_SEAM, &|t| t * TAU)?
+ } else {
+ let t0 = (a0 / TAU).rem_euclid(1.0);
+ let t1 = t0 + (a1 - a0) / TAU;
+ let angle = |t: f32| a0 + (t - t0) * TAU;
+ if t1 <= 1.0 {
+ read(t0, t1, &angle)?
+ } else {
+ // The turns it shows run on past the seam: those after it first, then those before.
+ let mut out = read(0.0, t1 - 1.0, &|t| angle(t + 1.0))?;
+ out.extend(read(t0, 1.0 - SWEEP_SEAM, &angle)?);
+ out
+ }
+ };
+ if out.len() > MAX_FUNCTION_STOPS {
+ return None;
+ }
+ let mut geom = GradientGeom { start: Point::ZERO, end: Point::new(1.0, 0.0), aspect: 1.0, focal: None };
+ geom.transform(sp.matrix * *matrix, GradientKind::Conic);
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Conic, out));
+ g.geom = Some(geom);
+ // It paints only over its domain ([`extend_clip`]).
+ Some(g)
+}
+
+/// How far short of a whole turn [`function_sweep`] reads the last stop: just before the seam.
+const SWEEP_SEAM: f32 = 1e-4;
+
+/// The angles (radians, the first the smaller, less than half a turn apart) at which rays from the
+/// origin cross domain `[x0, x1, y0, y1]`, which does not hold the origin, kept a little inside the
+/// angles of its corners; `None` when the domain shows no angles.
+fn seen_angles([x0, x1, y0, y1]: [f32; 4]) -> Option<(f32, f32)> {
+ use std::f32::consts::{PI, TAU};
+ let mid = ((y0 + y1) / 2.0).atan2((x0 + x1) / 2.0);
+ let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
+ for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] {
+ // A corner on the origin has no angle.
+ if x.hypot(y) > 1e-6 {
+ let r = (y.atan2(x) - mid + PI).rem_euclid(TAU) - PI;
+ (lo, hi) = (lo.min(r), hi.max(r));
+ }
+ }
+ let margin = (hi - lo) * 0.01;
+ (hi - lo > 1e-4 && (hi - lo).is_finite()).then_some((mid + lo + margin, mid + hi - margin))
+}
+
+/// Where the ray from the origin at angle `a` (radians) runs through domain `[x0, x1, y0, y1]`:
+/// its distances from the origin where it enters (0 when the domain holds the origin) and
+/// leaves; `None` when it misses the domain.
+fn ray_in_domain(a: f32, [x0, x1, y0, y1]: [f32; 4]) -> Option<(f32, f32)> {
+ let (mut near, mut far) = (0.0f32, f32::INFINITY);
+ for (d, lo, hi) in [(a.cos(), x0, x1), (a.sin(), y0, y1)] {
+ if d.abs() < 1e-6 {
+ if lo > 0.0 || hi < 0.0 {
+ return None;
+ }
+ continue;
+ }
+ let (t0, t1) = (lo / d, hi / d);
+ (near, far) = (near.max(t0.min(t1)), far.min(t0.max(t1)));
+ }
+ (far > near && far.is_finite()).then_some((near, far))
+}
+
+/// The stops of a function-based shading's function `function` read along `point` (0..1 to a
+/// point of the domain): sampled at [`FUNCTION_SAMPLES`] even steps, each hard color change two
+/// stops at one offset, and stops between which the color runs straight left out. `None` when
+/// they would be more than [`MAX_FUNCTION_STOPS`], or `budget` runs out (leaving
+/// [`AVERAGE_EVALUATIONS`]).
+fn function_stops(
+ sp: &ShadingPattern,
+ function: &ShadingFunction,
+ colors: &mut Colors<'_>,
+ inks: Option<&[String]>,
+ budget: &FunctionBudget,
+ point: &dyn Fn(f32) -> (f32, f32),
+) -> Option> {
+ let rgb = |x: f32, y: f32| function_rgb(sp, function, x, y, budget, AVERAGE_EVALUATIONS);
let color_of = |s: f32| {
let (x, y) = point(s);
rgb(x, y)
@@ -311,19 +469,7 @@ fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<
out.push(shading_stop(colors, space, value(read).as_deref(), offset, rgb));
}
}
- if out.len() > MAX_FUNCTION_STOPS {
- return None;
- }
- let p = |s: f32| {
- let (x, y) = point(s);
- Point::new(x as f64, y as f64)
- };
- let mut geom = GradientGeom { start: p(0.0), end: p(1.0), aspect: 1.0, focal: None };
- geom.transform(sp.matrix * *matrix, GradientKind::Linear);
- let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, out));
- g.geom = Some(geom);
- // It paints only over its domain ([`extend_clip`]).
- Some(g)
+ (out.len() <= MAX_FUNCTION_STOPS).then_some(out)
}
/// The colour a function-based shading averages to over its domain, for one that has no gradient
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index af744437d5..174172f408 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -15,6 +15,7 @@ mod cmykblend;
mod cmykimages;
mod colorize;
mod compression;
+mod conic;
mod dashalign;
mod editing;
mod encrypt;
diff --git a/crates/pdf/src/tests/conic.rs b/crates/pdf/src/tests/conic.rs
new file mode 100644
index 0000000000..477a4fb662
--- /dev/null
+++ b/crates/pdf/src/tests/conic.rs
@@ -0,0 +1,190 @@
+//! Conic gradients as function-based shadings (ISO 32000-1, 8.7.4.5.2) whose calculator function
+//! turns the angle about the center into the ramp: written exactly, read back as conic gradients,
+//! and drawn as images where a sweep shading can't be written.
+
+use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
+use vectorcraft_doc::{Appearance, Document, Node};
+use vectorcraft_geom::{Point, Rect, shapes};
+use vectorcraft_testkit::raster::{assert_similar, render_artboard};
+
+use crate::*;
+
+/// A page-sized square filled with `gradient` placed on `geom`.
+fn doc(gradient: Gradient, geom: GradientGeom) -> Document {
+ let mut gp = GradientPaint::new(gradient);
+ gp.geom = Some(geom);
+ let mut d = Document::new(200.0, 200.0);
+ let l = d.layers[0].id;
+ let 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();
+ d
+}
+
+/// Red to green to blue, once around.
+fn rgb_conic() -> Gradient {
+ let stops = 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)),
+ ];
+ Gradient::new(GradientKind::Conic, stops)
+}
+
+/// The first gradient fill in `d`.
+fn gradient(d: &Document) -> GradientPaint {
+ let mut out = None;
+ d.walk(|n| {
+ if let (None, Paint::Gradient(g)) = (&out, n.appearance.fill_paint()) {
+ out = Some(*g.clone());
+ }
+ });
+ out.expect("a gradient fill")
+}
+
+/// Held while a test imports function-based shadings, so tests that count evaluations count only
+/// their own ([`super::import_fidelity::SHADINGS`]).
+fn shadings() -> std::sync::MutexGuard<'static, ()> {
+ super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+fn uncompressed(d: &Document, o: &PdfOptions) -> (String, Vec) {
+ let report = export_with_report(d, o).unwrap();
+ (String::from_utf8_lossy(&report.bytes).into_owned(), report.warnings)
+}
+
+#[test]
+fn a_conic_is_written_as_a_sweep_shading() {
+ let geom = GradientGeom { start: Point::new(100.0, 100.0), end: Point::new(180.0, 60.0), aspect: 0.6, focal: None };
+ let (text, warnings) = uncompressed(&doc(rgb_conic(), geom), &PdfOptions::uncompressed());
+ assert!(warnings.is_empty(), "{warnings:?}");
+ assert!(text.contains("/ShadingType 1"), "a function-based shading");
+ assert!(text.contains("/FunctionType 4") && text.contains("atan"), "its function turns the angle into the ramp");
+ assert!(!text.contains("/ShadingType 2") && !text.contains("/ShadingType 3"), "no axial or radial stand-in");
+ // One without an extent still has its sweep.
+ let c = Point::new(100.0, 100.0);
+ let (text, _) = uncompressed(&doc(rgb_conic(), GradientGeom { start: c, end: c, aspect: 1.0, focal: None }), &PdfOptions::uncompressed());
+ assert!(text.contains("/ShadingType 1"));
+}
+
+/// Written and read back, a conic is a conic on the same frame with the same colors around it,
+/// squashed, turned or mirrored.
+#[test]
+fn a_conic_comes_back_as_a_conic() {
+ let _held = shadings();
+ let c = Point::new(100.0, 100.0);
+ for geom in [
+ GradientGeom { start: c, end: Point::new(170.0, 100.0), aspect: 1.0, focal: None },
+ GradientGeom { start: Point::new(90.0, 110.0), end: Point::new(150.0, 60.0), aspect: 0.5, focal: None },
+ GradientGeom { start: c, end: Point::new(100.0, 40.0), aspect: -1.0, focal: None },
+ ] {
+ let d = doc(rgb_conic(), geom);
+ let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
+ let g = gradient(&back);
+ assert_eq!(g.gradient.kind, GradientKind::Conic, "{geom:?}");
+ let got = g.geom.unwrap();
+ assert!(got.start.distance(geom.start) < 0.05 && got.is_mirrored() == geom.is_mirrored(), "{geom:?} → {got:?}");
+ for p in [Point::new(30.0, 100.0), Point::new(100.0, 170.0), Point::new(140.0, 60.0), Point::new(60.0, 40.0), Point::new(185.0, 130.0)] {
+ let want = rgb_conic().sample(geom.param_at(GradientKind::Conic, p) as f32).0.to_rgb();
+ let have = g.gradient.sample(got.param_at(GradientKind::Conic, p) as f32).0.to_rgb();
+ assert!(want.iter().zip(have).all(|(a, b)| (a - b).abs() < 0.03), "{geom:?} at {p:?}: {want:?} vs {have:?}");
+ }
+ assert_similar(&render_artboard(&back), &render_artboard(&d), 4.0, 0.01);
+ }
+}
+
+/// A conic whose center lies off the painted square, so the shading's domain does not hold its
+/// origin, comes back as a conic with the colors the square shows, the seam among them or not.
+#[test]
+fn a_conic_centered_off_the_object_comes_back_as_a_conic() {
+ let _held = shadings();
+ for geom in [
+ // The seam points into the square.
+ GradientGeom { start: Point::new(-50.0, 100.0), end: Point::new(30.0, 100.0), aspect: 1.0, focal: None },
+ // The seam points away from it.
+ GradientGeom { start: Point::new(-50.0, 100.0), end: Point::new(-50.0, 20.0), aspect: 1.0, focal: None },
+ // Above it, mirrored and squashed.
+ GradientGeom { start: Point::new(120.0, -60.0), end: Point::new(170.0, -60.0), aspect: -0.5, focal: None },
+ ] {
+ let d = doc(rgb_conic(), geom);
+ let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
+ let g = gradient(&back);
+ assert_eq!(g.gradient.kind, GradientKind::Conic, "{geom:?}");
+ let got = g.geom.unwrap();
+ assert!(got.start.distance(geom.start) < 0.05 && got.is_mirrored() == geom.is_mirrored(), "{geom:?} → {got:?}");
+ // Off the seam: on it, either side's color is right.
+ for p in [Point::new(5.0, 112.0), Point::new(100.0, 195.0), Point::new(195.0, 5.0), Point::new(60.0, 40.0), Point::new(185.0, 130.0)] {
+ let want = rgb_conic().sample(geom.param_at(GradientKind::Conic, p) as f32).0.to_rgb();
+ let have = g.gradient.sample(got.param_at(GradientKind::Conic, p) as f32).0.to_rgb();
+ assert!(want.iter().zip(have).all(|(a, b)| (a - b).abs() < 0.03), "{geom:?} at {p:?}: {want:?} vs {have:?}");
+ }
+ assert_similar(&render_artboard(&back), &render_artboard(&d), 4.0, 0.01);
+ }
+}
+
+/// A conic with translucent stops comes back as a conic with their opacity.
+#[test]
+fn a_translucent_conic_comes_back_with_its_opacity() {
+ let _held = shadings();
+ let mut ramp = rgb_conic();
+ ramp.stops[1].opacity = 0.4;
+ let geom = GradientGeom { start: Point::new(100.0, 100.0), end: Point::new(170.0, 100.0), aspect: 1.0, focal: None };
+ let d = doc(ramp.clone(), geom);
+ let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
+ let g = gradient(&back);
+ assert_eq!(g.gradient.kind, GradientKind::Conic);
+ let got = g.geom.unwrap();
+ for p in [Point::new(30.0, 100.0), Point::new(100.0, 170.0), Point::new(140.0, 60.0), Point::new(185.0, 130.0)] {
+ let (wc, wo) = ramp.sample(geom.param_at(GradientKind::Conic, p) as f32);
+ let (hc, ho) = g.gradient.sample(got.param_at(GradientKind::Conic, p) as f32);
+ assert!((wo - ho).abs() < 0.03, "at {p:?}: opacity {wo} vs {ho}");
+ assert!(wc.to_rgb().iter().zip(hc.to_rgb()).all(|(a, b)| (a - b).abs() < 0.03), "at {p:?}");
+ }
+ assert_similar(&render_artboard(&back), &render_artboard(&d), 4.0, 0.01);
+}
+
+/// A sweep shading holds up to 97 stops: a conic with that many is a sweep, and one more makes
+/// it an image.
+#[test]
+fn a_conic_of_97_stops_is_a_sweep_and_98_an_image() {
+ let geom = GradientGeom { start: Point::new(100.0, 100.0), end: Point::new(180.0, 100.0), aspect: 1.0, focal: None };
+ let stops = |n: usize| -> Gradient {
+ let v = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, Color::rgb((i % 2) as f32, 0.0, 1.0 - (i % 2) as f32))).collect();
+ Gradient::new(GradientKind::Conic, v)
+ };
+ for (n, sweep) in [(97, true), (98, false)] {
+ let g = stops(n);
+ assert_eq!(g.expanded().count(), n, "every stop is its own");
+ let (text, warnings) = uncompressed(&doc(g, geom), &PdfOptions::uncompressed());
+ assert_eq!(text.contains("/FunctionType 4"), sweep, "{n} stops");
+ assert_eq!(text.contains("/ShadingType 1"), sweep, "{n} stops");
+ assert_eq!(warnings.iter().any(|w| w.contains("conic gradients") && w.contains("images")), !sweep, "{n} stops: {warnings:?}");
+ }
+}
+
+/// PDF/A forbids calculator functions, and a sweep shading holds at most 97 stops: such a conic
+/// is drawn as an image of its colors, with a warning.
+#[test]
+fn a_conic_a_sweep_cannot_hold_is_an_image() {
+ let _held = shadings();
+ let geom = GradientGeom { start: Point::new(100.0, 100.0), end: Point::new(180.0, 100.0), aspect: 1.0, focal: None };
+ let mut o = PdfOptions::uncompressed();
+ (o.settings.standard, o.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version());
+ let many: Vec =
+ (0..=120).map(|i| GradientStop::new(i as f32 / 120.0, Color::rgb(i as f32 / 120.0, 0.0, 1.0 - i as f32 / 120.0))).collect();
+ for (case, d, o) in
+ [("PDF/A", doc(rgb_conic(), geom), o), ("121 stops", doc(Gradient::new(GradientKind::Conic, many), geom), PdfOptions::uncompressed())]
+ {
+ let (text, warnings) = uncompressed(&d, &o);
+ assert!(!text.contains("/FunctionType 4") && !text.contains("atan"), "{case}: no calculator function");
+ assert!(text.contains("/Image") && !text.contains("/ShadingType"), "{case}: an image of the sweep");
+ assert!(warnings.iter().any(|w| w.contains("conic gradients") && w.contains("images")), "{case}: {warnings:?}");
+ // Read back, the image shows the sweep where the canvas draws it.
+ let back = import(&export(&d, &o).unwrap()).unwrap();
+ let (want, got) = (render_artboard(&d), render_artboard(&back));
+ for (x, y) in [(150, 120), (100, 160), (40, 100), (100, 40), (160, 80), (30, 30), (170, 170)] {
+ let (a, b) = (want.over_white(x, y), got.over_white(x, y));
+ assert!(a.iter().zip(b).all(|(p, q)| p.abs_diff(q) <= 12), "{case} at ({x}, {y}): {a:?} vs {b:?}");
+ }
+ }
+}
diff --git a/crates/pdf/src/tests/import_fidelity.rs b/crates/pdf/src/tests/import_fidelity.rs
index e60f3e98c5..f25059c053 100644
--- a/crates/pdf/src/tests/import_fidelity.rs
+++ b/crates/pdf/src/tests/import_fidelity.rs
@@ -685,6 +685,74 @@ fn a_function_based_shading_along_one_axis_becomes_a_linear_gradient() {
}
}
+/// A function-based shading whose calculator function turns the angle about the origin into a gray
+/// ramp (`atan` gives the angle of `y` over `x` in degrees, 0 to 360, ISO 32000-1 7.10.5) comes in
+/// as a conic gradient about the origin's place on the page, turning the way the page shows it.
+#[test]
+fn a_function_based_shading_around_the_origin_becomes_a_conic_gradient() {
+ use vectorcraft_geom::Point;
+ let _held = shadings();
+ // The origin at the square's center, a unit 50 pt.
+ let r = function_shading("-1 1 -1 1", "50 0 0 50 50 50", "{ exch atan 360 div dup dup }");
+ assert!(!r.warnings.iter().any(|w| w.contains("flat")), "{:?}", r.warnings);
+ let g = all(&r.document)
+ .into_iter()
+ .find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
+ Some(Paint::Gradient(g)) => Some(g.clone()),
+ _ => None,
+ })
+ .expect("a gradient fill");
+ assert_eq!(g.gradient.kind, GradientKind::Conic);
+ let geom = g.geom.unwrap();
+ assert!(geom.start.distance(Point::new(50.0, 50.0)) < 0.01, "{geom:?}");
+ // The page's y runs up, the document's down: the sweep turns counterclockwise on the page,
+ // through the top of the square a quarter turn on.
+ assert!(geom.is_mirrored(), "{geom:?}");
+ for (x, y, want) in [(90.0, 48.0, 0.0), (50.0, 10.0, 0.25), (10.0, 50.0, 0.5), (50.0, 90.0, 0.75), (90.0, 52.0, 1.0)] {
+ let gray = g.gradient.sample(geom.param_at(GradientKind::Conic, Point::new(x, y)) as f32).0.to_rgb()[0];
+ assert!((gray - want).abs() < 0.03, "({x}, {y}): {gray} vs {want}");
+ }
+}
+
+/// The same shading over a domain that leaves the origin out (the conic's center lies off the
+/// painted area) comes in as a conic gradient too, with the colors the domain shows, whether or not
+/// the seam (angle 0) runs through it.
+#[test]
+fn a_function_based_shading_beside_the_origin_becomes_a_conic_gradient() {
+ use vectorcraft_geom::Point;
+ let _held = shadings();
+ // The shading's x and y from a point of the 100 pt page (its y up, the document's down).
+ let turn = |x: f64, y: f64| {
+ let (sx, sy) = ((x - 50.0) / 50.0, (50.0 - y) / 50.0);
+ sy.atan2(sx).to_degrees().rem_euclid(360.0) / 360.0
+ };
+ for (domain, points) in [
+ // Right of the origin, across the seam.
+ ("0.2 1 -0.6 0.6", [(70.0, 30.0), (70.0, 70.0), (90.0, 45.0), (90.0, 55.0), (65.0, 25.0)]),
+ // Above it.
+ ("-1 1 0.2 1", [(50.0, 20.0), (20.0, 30.0), (80.0, 30.0), (5.0, 35.0), (95.0, 5.0)]),
+ // The origin on its left edge: the rays it shows run from straight down to straight up.
+ ("0 1 -1 1", [(52.0, 5.0), (52.0, 95.0), (90.0, 40.0), (90.0, 60.0), (75.0, 20.0)]),
+ ] {
+ let r = function_shading(domain, "50 0 0 50 50 50", "{ exch atan 360 div dup dup }");
+ let g = all(&r.document)
+ .into_iter()
+ .find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
+ Some(Paint::Gradient(g)) => Some(g.clone()),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("{domain}: a gradient fill, {:?}", r.warnings));
+ assert_eq!(g.gradient.kind, GradientKind::Conic, "{domain}");
+ let geom = g.geom.unwrap();
+ assert!(geom.start.distance(Point::new(50.0, 50.0)) < 0.01, "{domain}: {geom:?}");
+ for (x, y) in points {
+ let gray = g.gradient.sample(geom.param_at(GradientKind::Conic, Point::new(x, y)) as f32).0.to_rgb()[0];
+ let want = turn(x, y) as f32;
+ assert!((gray - want).abs() < 0.03, "{domain} ({x}, {y}): {gray} vs {want}");
+ }
+ }
+}
+
/// A function-based shading paints only over its domain: a square filled with one whose domain
/// covers its left half is clipped to that half; one whose domain covers it all isn't clipped.
#[test]
diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs
index 2238ab41e0..8da21a9c49 100644
--- a/crates/plugins/src/objects.rs
+++ b/crates/plugins/src/objects.rs
@@ -616,13 +616,20 @@ 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);
+ let conic = g.gradient.kind == vectorcraft_color::GradientKind::Conic;
if let Some(geom) = &mut g.geom {
check_point(geom.start, at)?;
check_point(geom.end, at)?;
if let Some(f) = geom.focal {
check_point(f, at)?;
}
- geom.aspect = if geom.aspect.is_finite() { geom.aspect.clamp(0.01, 100.0) } else { 1.0 };
+ let a = geom.aspect;
+ // A conic's negative aspect mirrors its sweep, so its sign stays.
+ geom.aspect = match (a.is_finite(), conic) {
+ (false, _) => 1.0,
+ (true, true) => a.abs().clamp(0.01, 100.0).copysign(a),
+ (true, false) => a.clamp(0.01, 100.0),
+ };
}
if let Some(ff) = &mut g.freeform {
if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) {
diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs
index c04ffe6a0c..0c4572fff3 100644
--- a/crates/plugins/src/objects/tests.rs
+++ b/crates/plugins/src/objects/tests.rs
@@ -67,6 +67,28 @@ fn objects_round_trip_through_the_json_model() {
assert_eq!(d, before);
}
+/// A mirrored conic gradient handed back keeps its negative aspect (its sweep stays mirrored),
+/// kept within the same range as other aspects; a radial's is kept positive.
+#[test]
+fn a_mirrored_conic_keeps_its_sign_through_a_plug_in() {
+ let (d, ids, _) = doc();
+ let a = ids[0].0;
+ let fill = |kind: &str, aspect: f64| {
+ let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
+ g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect, focal: None });
+ let mut p = serde_json::to_value(Paint::Gradient(Box::new(g))).unwrap();
+ p["gradient"]["kind"] = json!(kind);
+ p
+ };
+ for (kind, aspect, want) in [("Conic", -0.5, -0.5), ("Conic", -500.0, -100.0), ("Conic", 0.5, 0.5), ("Radial", -0.5, 0.01)] {
+ let mut d = d.clone();
+ let out = decode_json(json!({"objects": [{"id": a, "fills": [fill(kind, aspect)]}]})).unwrap();
+ apply_output(&mut d, &ids, out, None, &keep).unwrap();
+ let Paint::Gradient(g) = d.node(ids[0]).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
+ assert_eq!(g.geom.unwrap().aspect, want, "{kind} {aspect}");
+ }
+}
+
/// A path handed back as it was stays the live shape it was (a rectangle keeps its corner
/// widgets); a path that changed is a plain path.
#[test]
diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs
index 003a08807d..e26977c139 100644
--- a/crates/render/src/paint.rs
+++ b/crates/render/src/paint.rs
@@ -40,6 +40,12 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
};
g.with_stops(stops.as_slice())
}
+ 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.
+ 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())
+ }
_ => {
ctx.reset_paint_transform();
let (s, e) =
diff --git a/crates/render/tests/it/conic.rs b/crates/render/tests/it/conic.rs
new file mode 100644
index 0000000000..300a754d53
--- /dev/null
+++ b/crates/render/tests/it/conic.rs
@@ -0,0 +1,117 @@
+//! Conic gradients: the pixels sweep clockwise around the center from the vector, with a hard seam
+//! where the last stop meets the first, and follow the model's parameter when the extent ellipse
+//! is squashed, turned or mirrored, or holds numbers out of range.
+
+use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
+use vectorcraft_doc::{Appearance, Document};
+use vectorcraft_geom::{Point, Rect, shapes};
+use vectorcraft_testkit::fixtures::DocBuilder;
+use vectorcraft_testkit::raster::{Image, render_view};
+
+/// A 100 pt square filled with `gradient` placed on `geom`.
+fn square(gradient: Gradient, geom: GradientGeom) -> Document {
+ let mut gp = GradientPaint::new(gradient);
+ gp.geom = Some(geom);
+ let mut b = DocBuilder::new(100.0, 100.0);
+ b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0), |_| {});
+ b.build()
+}
+
+/// A white-to-black conic on `geom`.
+fn conic(geom: GradientGeom) -> Document {
+ square(Gradient { kind: GradientKind::Conic, ..Gradient::default() }, geom)
+}
+
+/// Centered on the square, the sweep starting at `deg` (counterclockwise from the right).
+fn at(deg: f64) -> GradientGeom {
+ GradientGeom::fit(GradientKind::Conic, Rect::new(0.0, 0.0, 100.0, 100.0), deg)
+}
+
+/// Every 7th pixel away from the center and the seam has the white-to-black ramp's gray at the
+/// model's parameter there (pixel centers).
+fn assert_follows_model(img: &Image, geom: &GradientGeom) {
+ for y in (3..100).step_by(7) {
+ for x in (3..100).step_by(7) {
+ let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
+ let t = geom.param_at(GradientKind::Conic, p);
+ if p.distance(geom.start) < 6.0 || !(0.02..0.98).contains(&t) {
+ continue;
+ }
+ let want = ((1.0 - t) * 255.0).round() as u8;
+ let got = img.over_white(x, y)[0];
+ assert!(got.abs_diff(want) <= 6, "({x},{y}) t={t:.3}: got {got}, want {want}");
+ }
+ }
+}
+
+#[test]
+fn the_sweep_runs_clockwise_from_the_vector() {
+ for deg in [0.0, 90.0, 180.0, 270.0, 33.0] {
+ let geom = at(deg);
+ let img = render_view(&conic(geom), 100, 100);
+ assert_follows_model(&img, &geom);
+ }
+ // From a vector pointing right: a quarter turn on (below the center) is three quarters white,
+ // three quarters on (above it) a quarter white.
+ let img = render_view(&conic(at(0.0)), 100, 100);
+ assert!(img.over_white(50, 80)[0].abs_diff(191) <= 6, "{:?}", img.over_white(50, 80));
+ assert!(img.over_white(50, 20)[0].abs_diff(64) <= 6, "{:?}", img.over_white(50, 20));
+}
+
+#[test]
+fn the_seam_is_hard_where_the_last_stop_meets_the_first() {
+ let img = render_view(&conic(at(0.0)), 100, 100);
+ // Along the vector (right of the center): white just below it, black just above it.
+ for x in [70, 80, 95] {
+ assert!(img.over_white(x, 52)[0] > 235, "x {x} below: {:?}", img.over_white(x, 52));
+ assert!(img.over_white(x, 47)[0] < 20, "x {x} above: {:?}", img.over_white(x, 47));
+ }
+ // Left of the center the ramp runs on smoothly.
+ for x in [5, 20, 30] {
+ assert!(img.over_white(x, 47)[0].abs_diff(img.over_white(x, 52)[0]) < 20, "x {x}");
+ }
+}
+
+#[test]
+fn a_squashed_turned_or_mirrored_extent_renders_as_its_model() {
+ let squashed = GradientGeom { start: Point::new(45.0, 55.0), end: Point::new(75.0, 70.0), aspect: 0.4, focal: None };
+ assert_follows_model(&render_view(&conic(squashed), 100, 100), &squashed);
+ // A negative aspect turns the sweep counterclockwise: a quarter turn on is above the center.
+ let mirrored = GradientGeom { aspect: -1.0, ..at(0.0) };
+ let img = render_view(&conic(mirrored), 100, 100);
+ assert_follows_model(&img, &mirrored);
+ assert!(img.over_white(50, 20)[0].abs_diff(191) <= 6, "{:?}", img.over_white(50, 20));
+}
+
+#[test]
+fn stop_colors_and_opacity_follow_the_ramp() {
+ let mut clear_blue = GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0));
+ clear_blue.opacity = 0.0;
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), clear_blue];
+ let img = render_view(&square(Gradient::new(GradientKind::Conic, stops), at(0.0)), 100, 100);
+ // Just past the start: opaque red. Just before the seam: nearly clear, the paper showing.
+ let start = img.over_white(90, 52);
+ assert!(start[0] > 235 && start[1] < 30 && start[2] < 30, "{start:?}");
+ assert!(img.over_white(90, 47).iter().all(|&c| c > 200), "{:?}", img.over_white(90, 47));
+}
+
+/// A conic whose extent a file left without length, flat or out of range draws as the model
+/// samples it: a sweep about its start from its vector's direction, or from the right when the
+/// vector has no length.
+#[test]
+fn a_conic_without_an_extent_sweeps_from_its_vector() {
+ let c = Point::new(50.0, 50.0);
+ for geom in [
+ GradientGeom { start: c, end: c, aspect: 1.0, focal: None },
+ GradientGeom { start: c, end: Point::new(80.0, 20.0), aspect: 0.0, focal: None },
+ GradientGeom { start: c, end: Point::new(80.0, 20.0), aspect: f64::NAN, focal: None },
+ GradientGeom { start: c, end: Point::new(80.0, 20.0), aspect: f64::INFINITY, focal: None },
+ GradientGeom { start: c, end: c, aspect: -1.0, focal: None },
+ ] {
+ let img = render_view(&conic(geom), 100, 100);
+ assert_follows_model(&img, &geom);
+ }
+ // Without a vector, from the right: a quarter turn on is below the center.
+ let img = render_view(&conic(GradientGeom { start: c, end: c, aspect: 1.0, focal: None }), 100, 100);
+ assert!(img.over_white(50, 80)[0].abs_diff(191) <= 6, "{:?}", img.over_white(50, 80));
+}
diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs
index 67afd69936..9059c3a218 100644
--- a/crates/render/tests/it/main.rs
+++ b/crates/render/tests/it/main.rs
@@ -2,6 +2,7 @@
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod conic;
mod dash_align;
mod golden;
mod gradient_alpha;
diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs
index 115d6f709a..a60b7d4045 100644
--- a/crates/svg/src/css.rs
+++ b/crates/svg/src/css.rs
@@ -555,7 +555,7 @@ impl Rules<'_> {
}
}
- /// A linear or radial gradient over `b` at `opacity`, with the stops SVG export writes
+ /// A linear, radial or conic gradient over `b` at `opacity`, with the stops SVG export 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);
@@ -593,6 +593,27 @@ impl Rules<'_> {
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)))
}
+ GradientKind::Conic => {
+ // CSS sweeps clockwise from the `from` angle, 0deg straight up
+ // (https://www.w3.org/TR/css-images-4/#conic-gradients): the vector's direction is
+ // where the sweep starts. A mirrored sweep turns the other way, so its stops are
+ // written mirrored about the seam; CSS has no elliptical sweep.
+ let frame = geom.conic_frame();
+ let v = frame * Point::new(1.0, 0.0) - frame * Point::ORIGIN;
+ let from = v.x.atan2(-v.y).to_degrees().rem_euclid(360.0);
+ 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();
+ if geom.is_mirrored() {
+ stops.reverse();
+ stops.iter_mut().for_each(|s| s.0 = 1.0 - s.0);
+ }
+ let stops: Vec =
+ stops.iter().map(|(t, c, o)| format!("{} {}%", color_css(c, o * opacity), fmt_num(f64::from(*t) * 100.0, 2))).collect();
+ let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0));
+ Some(format!("conic-gradient(from {}deg {at}, {})", fmt_num(from, 2), stops.join(", ")))
+ }
GradientKind::Freeform => None,
}
}
diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs
index 740d1b78f7..89fafe50a4 100644
--- a/crates/svg/src/export/paint.rs
+++ b/crates/svg/src/export/paint.rs
@@ -264,6 +264,11 @@ impl Writer<'_> {
// Object fills and strokes are images (see `raster_paint`); characters' paints aren't.
self.warn("freeform gradients on characters are written as linear gradients");
}
+ if g.gradient.kind == GradientKind::Conic {
+ // SVG has no conic gradient; object fills and strokes are wedges or images (see
+ // `raster_paint`).
+ self.warn("conic gradients on characters are written as linear gradients");
+ }
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
let (s, e) = (geom.start, geom.end);
@@ -283,7 +288,7 @@ impl Writer<'_> {
};
("radialGradient", "radial-gradient", attrs)
}
- GradientKind::Linear | GradientKind::Freeform => (
+ GradientKind::Linear | GradientKind::Freeform | GradientKind::Conic => (
"linearGradient",
"linear-gradient",
format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)),
@@ -430,36 +435,51 @@ impl Writer<'_> {
/// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled
/// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour
/// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size
- /// in points); a linear or radial gradient at the document's raster effects resolution (at most
- /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty
- /// box.
+ /// in points); a linear, radial or conic gradient at the document's raster effects resolution
+ /// (at most [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for
+ /// an empty box.
+ ///
+ /// A conic gradient (SVG has none) with opaque stops that isn't drawn as pixels is written as
+ /// [`CONIC_WEDGES`] solid wedges around its center in the same clip group instead, each
+ /// running on to the seam under the next ([`vectorcraft_color::GradientGeom::conic_wedges`]):
+ /// vector art with a sharp seam. With translucent stops, where wedges drawn over each other
+ /// would add up their opacity, it is an image.
#[allow(clippy::too_many_arguments)]
pub(super) fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) {
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let freeform = g.gradient.kind == GradientKind::Freeform;
- let (rgba, cols, rows): (Vec, u16, u16) = if freeform {
- let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
- let field = g.freeform_on(b).model_field(spread_scale(b));
- let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
- (field.display_grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
+ let conic = g.gradient.kind == GradientKind::Conic;
+ let wedges = conic && !g.draws_as_pixels(fill) && g.gradient.stops.iter().all(|s| s.opacity >= 1.0);
+ let href = if wedges {
+ self.warn("conic gradients are written as solid wedges clipped to their shapes");
+ None
} else {
- let k = self.doc.raster_effects_ppi / 72.0;
- let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
- let (cols, rows) = (along(b.width()), along(b.height()));
- (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows)
+ let (rgba, cols, rows): (Vec, u16, u16) = if freeform {
+ let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
+ let field = g.freeform_on(b).model_field(spread_scale(b));
+ let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
+ (field.display_grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
+ } else {
+ let k = self.doc.raster_effects_ppi / 72.0;
+ let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
+ let (cols, rows) = (along(b.width()), along(b.height()));
+ (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows)
+ };
+ let mut png = Vec::new();
+ let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return };
+ if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
+ return;
+ }
+ self.warn(if freeform {
+ "freeform gradients are written as images clipped to their shapes"
+ } else if conic {
+ "conic gradients with translucent stops, dither or the Perceptual method are written as images clipped to their shapes"
+ } else {
+ "linear and radial gradients drawn as pixels are written as images clipped to their shapes"
+ });
+ Some(self.blob_href(&ImageBlob::new("image/png", png)))
};
- let mut png = Vec::new();
- let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return };
- if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
- return;
- }
- self.warn(if freeform {
- "freeform gradients are written as images clipped to their shapes"
- } else {
- "linear and radial gradients drawn as pixels are written as images clipped to their shapes"
- });
- let href = self.blob_href(&ImageBlob::new("image/png", png));
let cid = self.fresh_id("clip-path");
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(1, &format!(""));
@@ -477,12 +497,23 @@ impl Writer<'_> {
self.line(&format!(""));
}
self.depth += 1;
- let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
- let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) };
- self.line(&format!(
- "",
- xml_escape(&href)
- ));
+ match href {
+ Some(href) => {
+ let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
+ let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) };
+ self.line(&format!(
+ "",
+ xml_escape(&href)
+ ));
+ }
+ None => {
+ let sample = g.gradient.sampler();
+ for (pts, t) in g.resolve(bounds).conic_wedges(b, 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.depth -= 1;
self.line(" ");
if nested {
@@ -669,14 +700,18 @@ impl Writer<'_> {
}
}
-/// Most pixels along a side of a linear or radial gradient's image.
+/// Most pixels along a side of a linear, radial or conic gradient's image.
const MAX_GRADIENT_PX: f64 = 4096.0;
+/// Wedges a conic gradient with opaque stops is written as: one a degree.
+const CONIC_WEDGES: usize = 360;
+
/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels
-/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one).
+/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one) or is a conic
+/// gradient (SVG has none either: [`Writer::raster_paint`] draws it as wedges or an image).
pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> {
match p {
- Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g),
+ Paint::Gradient(g) if g.draws_as_pixels(fill) || g.gradient.kind == GradientKind::Conic => Some(g),
_ => None,
}
}
diff --git a/crates/svg/tests/it/conic.rs b/crates/svg/tests/it/conic.rs
new file mode 100644
index 0000000000..ce8ae1216b
--- /dev/null
+++ b/crates/svg/tests/it/conic.rs
@@ -0,0 +1,84 @@
+//! Conic gradients, which SVG has no element for: fills and strokes of opaque stops are written
+//! as solid wedges around the center clipped to the shape (or the stroke's outline), others as an
+//! image, with a warning; read back, the SVG looks as the canvas paints the gradient, its seam
+//! still sharp.
+
+use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
+use vectorcraft_doc::{Appearance, Document, Node, NodeId};
+use vectorcraft_geom::{Point, Rect, shapes};
+use vectorcraft_svg::{ExportOptions, export_full, import};
+use vectorcraft_testkit::raster::{assert_similar, render_artboard};
+
+/// `gradient` (white to black when `None`), sweeping clockwise from the right around (100, 80).
+fn conic(gradient: Option) -> Paint {
+ let mut g = GradientPaint::new(gradient.unwrap_or(Gradient { kind: GradientKind::Conic, ..Gradient::default() }));
+ g.geom = Some(GradientGeom { start: Point::new(100.0, 80.0), end: Point::new(160.0, 80.0), aspect: 1.0, focal: None });
+ Paint::Gradient(Box::new(g))
+}
+
+/// A 200 × 160 document holding `n`.
+fn doc_with(mut n: Node) -> Document {
+ let mut d = Document::new(200.0, 160.0);
+ n.id = d.alloc_id();
+ let l = d.layers[0].id;
+ d.insert(Some(l), 0, n).unwrap();
+ d
+}
+
+#[test]
+fn a_conic_fill_is_solid_wedges_clipped_to_the_shape() {
+ let shape = shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 150.0));
+ let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(conic(None), Paint::None, 0.0)));
+ let o = export_full(&d, &ExportOptions::default(), None);
+ assert!(!o.svg.contains("Gradient") && !o.svg.contains("| {
+ let mut g = GradientPaint::new(Gradient { kind: GradientKind::Conic, ..Gradient::default() });
+ g.geom = geom;
+ Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)
+ };
+ let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
+ n.appearance = conic(None).into();
+ let id = add(&mut d, n);
+ assert_eq!(
+ prop(&one(&d, id, &CssOptions::default()), "background-image"),
+ Some("conic-gradient(from 90deg at 50px 30px, #ffffff 0%, #000000 100%)")
+ );
+ // Pointing straight up and mirrored: it sweeps counterclockwise, so black comes first clockwise.
+ let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
+ n.appearance = conic(Some(GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.0, 0.0), aspect: -1.0, focal: None })).into();
+ let id = add(&mut d, n);
+ assert_eq!(
+ prop(&one(&d, id, &CssOptions::default()), "background-image"),
+ Some("conic-gradient(from 0deg at 40px 30px, #000000 0%, #ffffff 100%)")
+ );
+ let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
+ n.appearance = conic(Some(GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(70.0, 30.0), aspect: 0.5, focal: None })).into();
+ let id = add(&mut d, n);
+ let sheet = css_rules(&d, &[id], &CssOptions::default());
+ assert!(sheet.warnings.iter().any(|w| w.contains("conic gradients are written circular")), "{:?}", sheet.warnings);
+}
+
#[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 09c175a37a..30ab43108b 100644
--- a/crates/svg/tests/it/main.rs
+++ b/crates/svg/tests/it/main.rs
@@ -7,6 +7,7 @@ mod blend_export;
mod clip_text;
mod colorized_images;
mod colour_glyphs;
+mod conic;
mod container_export;
mod css_rules;
mod dash_align;
diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs
index d984504e1f..4cfa0932da 100644
--- a/crates/tools/src/xform/gradient.rs
+++ b/crates/tools/src/xform/gradient.rs
@@ -20,6 +20,10 @@
//! focal point (where the first stop sits): drag it to make the gradient off-centre, back onto
//! the centre to centre it.
//!
+//! A conic gradient shows the same extent ellipse, aspect handle and center ring, without a focal
+//! dot: the bar runs from the center to where the sweep starts, its stops along it by their share
+//! of the turn.
+//!
//! On a freeform gradient the tool edits its points instead (see `freeform`).
use serde_json::{Value, json};
@@ -99,12 +103,18 @@ impl Annotator {
fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self {
Self { geom, gradient: Gradient::new(GradientKind::Linear, stops.to_vec()) }
}
+ /// Placed by a center and an extent ellipse (radial and conic)?
fn radial(&self) -> bool {
+ self.gradient.kind.is_centered()
+ }
+ /// Has a focal point to drag (radial only)?
+ fn focal(&self) -> bool {
self.gradient.kind == GradientKind::Radial
}
- /// A radial's aspect handle: on the extent ellipse, across the bar from the stops.
+ /// A radial's (or conic's) aspect handle: on the extent ellipse, across the bar from the
+ /// stops (a mirrored conic's too: its negative aspect only turns the sweep the other way).
pub fn aspect_point(&self) -> Point {
- self.geom.start - self.normal() * (self.vector().hypot() * self.geom.aspect)
+ self.geom.start - self.normal() * (self.vector().hypot() * self.geom.aspect.abs())
}
/// How far `p` is from a radial's extent ellipse (approximately, along the ray from the
/// centre).
@@ -160,7 +170,7 @@ impl Annotator {
return Some(Part::End);
}
let radial = self.radial();
- if radial && near(Some(self.geom.focal_point()), FOCAL_HIT) {
+ if self.focal() && near(Some(self.geom.focal_point()), FOCAL_HIT) {
return Some(Part::Focal);
}
if near(Some(self.geom.start), if radial { CENTRE_HIT } else { HANDLE }) {
@@ -256,7 +266,7 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec {
o.extend([
Overlay::Line { a: s, b: e, color: BAR, dashed: false },
Overlay::Line { a: s + shade, b: e + shade, color: LIGHT, dashed: false },
- Overlay::Handle { p: if a.radial() { a.geom.focal_point() } else { s }, color: BAR },
+ Overlay::Handle { p: if a.focal() { a.geom.focal_point() } else { s }, color: BAR },
Overlay::Anchor { p: e, color: BAR, filled: true, size: 7.0 },
]);
let len = a.vector().hypot();
@@ -386,10 +396,12 @@ impl GradientTool {
("paint.setGradientGeom", params)
}
Grab::Aspect(geom) => {
- // The pointer's distance across the bar, as a fraction of the radius.
+ // The pointer's distance across the bar, as a fraction of the radius; a mirrored
+ // conic (negative aspect) stays mirrored.
let across = Annotator::with(*geom, &[]).project(p).1.abs();
let mut params = Self::geom_params(cx, geom.start, geom.end);
- params["aspect"] = json!(across / geom.length().max(1e-12) * 100.0);
+ let sign = if geom.is_mirrored() { -1.0 } else { 1.0 };
+ params["aspect"] = json!(sign * across / geom.length().max(1e-12) * 100.0);
("paint.setGradientGeom", params)
}
Grab::Stop { index, from, copy } => {
diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs
index c6582152b2..436470a2d7 100644
--- a/crates/tools/src/xform/gradient/tests.rs
+++ b/crates/tools/src/xform/gradient/tests.rs
@@ -366,3 +366,34 @@ fn a_radial_shows_its_extent_ellipse_aspect_handle_and_focal_dot() {
assert_eq!((hit(100.0, 200.0), hit(0.0, 150.0), hit(100.0, 220.0)), (Some(Part::Rotate), Some(Part::Rotate), None));
assert_eq!(GradientTool::default().cursor(&cx, Point::new(100.0, 200.0), Mods::default()), Cursor::Rotate);
}
+
+/// A conic shows the extent ellipse, aspect handle and center ring of a radial without a focal
+/// dot; mirrored (a negative aspect), its aspect handle is still across the bar from the stops,
+/// and dragging it keeps the mirror.
+#[test]
+fn a_conic_shows_its_extent_ellipse_and_aspect_handle_without_a_focal_dot() {
+ for aspect in [0.5, -0.5] {
+ 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 = GradientKind::Conic;
+ g.geom.as_mut().unwrap().aspect = aspect;
+ n.appearance.set_fill(Paint::Gradient(g));
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let o = GradientTool::default().overlays(&cx);
+ assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the extent ellipse");
+ // The aspect handle 50 pt above the center, the start handle on the center.
+ assert!(o.contains(&Overlay::Handle { p: Point::new(100.0, 100.0), color: BAR }), "{aspect}: {o:?}");
+ assert!(o.contains(&Overlay::Handle { p: Point::new(100.0, 150.0), color: BAR }), "{aspect}: {o:?}");
+ let a = Annotator::of(&cx).unwrap();
+ let hit = |x: f64, y: f64| a.hit(&cx, Point::new(x, y));
+ assert_eq!((hit(100.0, 150.0), hit(100.0, 100.0), hit(0.0, 150.0)), (Some(Part::Start), Some(Part::Aspect), Some(Part::Rotate)));
+ // Dragged 20 pt further out, the aspect grows to 70% and keeps its sign.
+ let mut t = GradientTool::default();
+ t.pointer(&cx, &ev(PointerKind::Down, Point::new(100.0, 100.0)));
+ let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 80.0)))).clone();
+ let want = 70.0f64.copysign(aspect);
+ assert!((v["aspect"].as_f64().unwrap() - want).abs() < 1e-9, "{aspect}: {v}");
+ }
+}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 5d2b382604..72c12adc8b 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2539,6 +2539,7 @@ digits Malayalam Малаялам
Radial Радиален
Radial Glows Лъчисти сияния
Radial Gradient Лъчист градиент
+ Conic Gradient Конусен градиент
Radius 1 Радиус 1
Radius 2 Радиус 2
Radius1 Радиус1
@@ -4075,6 +4076,8 @@ axis Both И двете
@msg `gradient` must be an object `gradient` трябва да бъде обект
@msg `library` is a library id or name (see {list}) `library` е идентификатор или име на библиотека (вижте {list})
@msg `mode` applies to freeform gradients `mode` се прилага към свободни разградки
+@msg a negative `aspect` applies to conic gradients отрицателен `aspect` се прилага към конусни градиенти
+@msg `focal` applies to radial gradients `focal` се прилага към радиални градиенти
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` трябва да бъде 1, 3 или 5
@msg `stops` must be an array `stops` трябва да бъде масив
@msg `svg` must be an object of SVG options (see document.formats) `svg` трябва да бъде обект с опции за SVG (вижте document.formats)
@@ -4531,7 +4534,7 @@ axis Both И двете
@msg unknown format непознат формат
@msg unknown format `{f}` (Save writes {_1}) непознат формат `{f}` (Запазването записва {_1})
@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (точки, линии)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) непознат вид градиент `{k}` (линеен, радиален, свободна форма)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) непознат вид градиент `{k}` (линеен, радиален, конусен, свободна форма)
@msg unknown highlight `{h}` непознат акцент `{h}`
@msg unknown option `{path}.{k}` непозната опция `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (запазване, симулиране или отхвърляне)
@@ -5624,7 +5627,7 @@ theme Appearance Външен вид
@msg span must be "object" or "selection", got {v} span трябва да е "object" или "selection", а не {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" поставя `gradient`, а не мостра
@msg span: "selection" places a gradient: give `gradient` span: "selection" поставя градиент: задайте `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" поставя линеен или радиален градиент, а не свободен
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" поставя линеен, радиален или конусен градиент, а не свободен
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" поставя самия градиент: пропуснете `start` и `end` (те вече го поставят в координатите на документа)
Make Image Dynamic Динамично изображение
Import Data Sets… Импортиране на набори от данни…
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index d32a2f8972..8a64e86437 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -1663,6 +1663,7 @@ digits Malayalam Malajalam
No gradient swatches Nema gradijentnih uzoraka boja
Points: clicks add free points Tačke: klikovi dodaju slobodne tačke
Radial Gradient Radijalni gradijent
+ Conic Gradient Konusni gradijent
Reset to White, Black Vrati na bijelu, crnu
Reverse Gradient Obrni gradijent
Save to Swatches Sačuvaj u uzorke boja
@@ -4442,6 +4443,8 @@ imageTrace Outline Obris
@msg `groupColors` must be an array `groupColors` mora biti niz
@msg `library` is a library id or name (see {list}) `library` je ID ili naziv biblioteke (vidi {list})
@msg `mode` applies to freeform gradients `mode` se primjenjuje na gradijente slobodnog oblika
+@msg a negative `aspect` applies to conic gradients negativan `aspect` se primjenjuje na konusne gradijente
+@msg `focal` applies to radial gradients `focal` se primjenjuje na radijalne gradijente
@msg `preserve` must be an object `preserve` mora biti objekat
@msg `globalAdjust` must be an object `globalAdjust` mora biti objekat
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` mora biti 1, 3 ili 5
@@ -4930,7 +4933,7 @@ imageTrace Outline Obris
@msg unknown format nepoznat format
@msg unknown format `{f}` (Save writes {_1}) nepoznat format `{f}` (Sačuvaj zapisuje {_1})
@msg unknown freeform mode `{m}` (points, lines) nepoznat način slobodnog oblika `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) nepoznata vrsta gradijenta `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) nepoznata vrsta gradijenta `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` nepoznato isticanje `{h}`
@msg unknown option `{path}.{k}` nepoznata opcija `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) nepoznati pretisci `{o}` (preserve, simulate ili discard)
@@ -5645,7 +5648,7 @@ mark-style Japanese Japanski
@msg span must be "object" or "selection", got {v} span mora biti "object" ili "selection", dobiveno {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" postavlja `gradient`, a ne uzorak boje
@msg span: "selection" places a gradient: give `gradient` span: "selection" postavlja gradijent: navedite `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" postavlja linearni ili radijalni gradijent, a ne gradijent slobodnog oblika
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" postavlja linearni, radijalni ili konusni gradijent, a ne gradijent slobodnog oblika
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" postavlja sam gradijent: izostavite `start` i `end` (oni ga već postavljaju u koordinatama dokumenta)
Make Image Dynamic Učini sliku dinamičkom
Import Data Sets… Uvezi skupove podataka…
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 4be1f70d4b..90ef9281af 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -3272,6 +3272,7 @@ digits Malayalam Malaiàlam
Radial Dividers: Divisors radials:
Radial Glows Resplendors radials
Radial Gradient Degradat radial
+ Conic Gradient Degradat cònic
Radial Repeat Repetició radial
Radius Radi
Radius 1 Radi 1
@@ -5017,6 +5018,8 @@ digits Malayalam Malaiàlam
@msg `method` must be classic or perceptual `method` ha de ser classic o perceptual
@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
+@msg `focal` applies to radial gradients `focal` s’aplica als degradats radials
@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` ha de ser grayscale, rgb, hsb, cmyk o websafe
@msg `name` is empty `name` és buit
@msg `newName` is empty `newName` és buit
@@ -5773,7 +5776,7 @@ digits Malayalam Malaiàlam
@msg span must be "object" or "selection", got {v} span ha de ser "object" o "selection", s’ha rebut {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" col·loca un `gradient`, no una mostra
@msg span: "selection" places a gradient: give `gradient` span: "selection" col·loca un degradat: dona `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" col·loca un degradat lineal o radial, no un de lliure
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" col·loca un degradat lineal, radial o cònic, no un de lliure
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" col·loca el degradat mateix: omet `start` i `end` (ja el col·loquen en coordenades del document)
@msg Split Into Grid: select paths large enough for the grid Divideix en quadrícula: selecciona traçats prou grans per a la quadrícula
@msg stop {i} needs `offset` la parada {i} necessita `offset`
@@ -5947,7 +5950,7 @@ digits Malayalam Malaiàlam
@msg unknown format format desconegut
@msg unknown format `{f}` (Save writes {_1}) format desconegut `{f}` (Desa escriu {_1})
@msg unknown freeform mode `{m}` (points, lines) mode de forma lliure desconegut `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) tipus de degradat desconegut `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) tipus de degradat desconegut `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` ressaltat desconegut `{h}`
@msg unknown option `{path}.{k}` opció desconeguda `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressions desconegudes `{o}` (preserve, simulate o discard)
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index ec594cf0e1..418faac352 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -1687,6 +1687,7 @@ digits Malayalam Malajálamské
No gradient swatches Ve vzorníku nejsou žádné přechody
Points: clicks add free points Body: kliknutím přidáte volné body
Radial Gradient Kruhový přechod
+ Conic Gradient Kuželový přechod
Reset to White, Black Obnovit na bílou a černou
Reverse Gradient Obrátit přechod
Save to Swatches Uložit do vzorníku
@@ -4516,6 +4517,8 @@ imageTrace Outline Obrys
@msg `groupColors` must be an array `groupColors` musí být pole
@msg `library` is a library id or name (see {list}) `library` je ID nebo název knihovny (viz {list})
@msg `mode` applies to freeform gradients `mode` platí pro volné přechody
+@msg a negative `aspect` applies to conic gradients záporný `aspect` platí pro kuželové přechody
+@msg `focal` applies to radial gradients `focal` platí pro radiální přechody
@msg `preserve` must be an object `preserve` musí být objekt
@msg `globalAdjust` must be an object `globalAdjust` musí být objekt
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5
@@ -5017,7 +5020,7 @@ imageTrace Outline Obrys
@msg unknown format neznámý formát
@msg unknown format `{f}` (Save writes {_1}) neznámý formát `{f}` (Uložit zapisuje {_1})
@msg unknown freeform mode `{m}` (points, lines) neznámý režim volného přechodu `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) neznámý druh přechodu `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) neznámý druh přechodu `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` neznámé zvýraznění `{h}`
@msg unknown option `{path}.{k}` neznámá možnost `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) neznámé přetisky `{o}` (preserve, simulate nebo discard)
@@ -5780,7 +5783,7 @@ mark-style Japanese Japonské
@msg span must be "object" or "selection", got {v} span musí být "object" nebo "selection", ne {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" umísťuje `gradient`, ne vzorník
@msg span: "selection" places a gradient: give `gradient` span: "selection" umísťuje přechod: zadejte `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" umísťuje lineární nebo radiální přechod, ne volný přechod
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" umísťuje lineární, radiální nebo kuželový přechod, ne volný přechod
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" umísťuje samotný přechod: vynechte `start` a `end` (ty ho už umísťují v souřadnicích dokumentu)
Make Image Dynamic Nastavit obraz jako dynamický
Import Data Sets… Importovat sady dat…
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index 08405d5712..2bd5639532 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -1598,6 +1598,7 @@ digits Malayalam Malayalam
No gradient swatches Ingen gradientfarveprøver
Points: clicks add free points Punkter: klik tilføjer frie punkter
Radial Gradient Radial gradient
+ Conic Gradient Konisk gradient
Reset to White, Black Nulstil til hvid, sort
Reverse Gradient Omvend gradient
Save to Swatches Gem i farveprøver
@@ -4192,6 +4193,8 @@ imageTrace Outline Kontur
@msg `gradient` must be an object `gradient` skal være et objekt
@msg `library` is a library id or name (see {list}) `library` er et biblioteks-id eller -navn (se {list})
@msg `mode` applies to freeform gradients `mode` gælder for freeform-gradienter
+@msg a negative `aspect` applies to conic gradients et negativt `aspect` gælder for koniske gradienter
+@msg `focal` applies to radial gradients `focal` gælder for radiale gradienter
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` skal være 1, 3 eller 5
@msg `stops` must be an array `stops` skal være et array
@msg `svg` must be an object of SVG options (see document.formats) `svg` skal være et objekt med SVG-indstillinger (se document.formats)
@@ -4627,7 +4630,7 @@ imageTrace Outline Kontur
@msg unknown format ukendt format
@msg unknown format `{f}` (Save writes {_1}) ukendt format `{f}` (Gem skriver {_1})
@msg unknown freeform mode `{m}` (points, lines) ukendt freeform-tilstand `{m}` (punkter, linjer)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) ukendt gradienttype `{k}` (lineær, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) ukendt gradienttype `{k}` (lineær, radial, konisk, freeform)
@msg unknown highlight `{h}` ukendt fremhævning `{h}`
@msg unknown option `{path}.{k}` ukendt indstilling `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (bevar, simulér eller forkast)
@@ -5380,7 +5383,7 @@ mark-style Japanese Japansk
@msg span must be "object" or "selection", got {v} span skal være "object" eller "selection", fik {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" placerer en `gradient`, ikke en farveprøve
@msg span: "selection" places a gradient: give `gradient` span: "selection" placerer en overgang: angiv `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" placerer en lineær eller radial overgang, ikke en fri
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" placerer en lineær, radial eller konisk overgang, ikke en fri
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" placerer overgangen selv: udelad `start` og `end` (de placerer den allerede i dokumentkoordinater)
Make Image Dynamic Gør billede dynamisk
Import Data Sets… Importér datasæt…
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index e9b0b416f6..c0201157d9 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2570,6 +2570,7 @@ digits Malayalam Malayalam
Radial Radial
Radial Glows Radiale Leuchteffekte
Radial Gradient Radialer Verlauf
+ Conic Gradient Konischer Verlauf
Radius 1 Radius 1
Radius 2 Radius 2
Radius1 Radius1
@@ -4123,6 +4124,8 @@ theme Appearance Erscheinungsbild
@msg `groupColors` must be an array `groupColors` muss ein Array sein
@msg `library` is a library id or name (see {list}) `library` ist die ID oder der Name einer Bibliothek (siehe {list})
@msg `mode` applies to freeform gradients `mode` gilt für Freiformverläufe
+@msg a negative `aspect` applies to conic gradients ein negatives `aspect` gilt für konische Verläufe
+@msg `focal` applies to radial gradients `focal` gilt für radiale Verläufe
@msg `preserve` must be an object `preserve` muss ein Objekt sein
@msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein
@@ -4626,7 +4629,7 @@ theme Appearance Erscheinungsbild
@msg unknown format unbekanntes Format
@msg unknown format `{f}` (Save writes {_1}) unbekanntes Format `{f}` („Speichern“ schreibt {_1})
@msg unknown freeform mode `{m}` (points, lines) unbekannter Freiformmodus `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) unbekannte Verlaufsart `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) unbekannte Verlaufsart `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` unbekannte Hervorhebung `{h}`
@msg unknown option `{path}.{k}` unbekannte Option `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) unbekannter Wert für overprints `{o}` (preserve, simulate oder discard)
@@ -5358,7 +5361,7 @@ effect Soft Weich
@msg span must be "object" or "selection", got {v} span muss "object" oder "selection" sein, nicht {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" platziert einen `gradient`, kein Farbfeld
@msg span: "selection" places a gradient: give `gradient` span: "selection" platziert einen Verlauf: geben Sie `gradient` an
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" platziert einen linearen oder radialen Verlauf, keinen Freiform-Verlauf
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" platziert einen linearen, radialen oder konischen Verlauf, keinen Freiform-Verlauf
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" platziert den Verlauf selbst: lassen Sie `start` und `end` weg (sie platzieren ihn bereits in Dokumentkoordinaten)
Make Image Dynamic Bild dynamisch machen
Import Data Sets… Datensätze importieren…
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index c12a6eace7..9c9b46326d 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -1898,6 +1898,7 @@ font-technology Variable Μεταβλητές
Quick Actions Γρήγορες ενέργειες
Radial Ακτινικό
Radial Gradient Ακτινωτό ντεγκραντέ
+ Conic Gradient Κωνικό ντεγκραντέ
Random Τυχαίο
Range: Εύρος:
Rasterize Μετατροπή σε εικονοστοιχεία
@@ -2354,6 +2355,8 @@ axis Both Και τα δύο
@msg `groupColors` must be an array Το `groupColors` πρέπει να είναι πίνακας
@msg `library` is a library id or name (see {list}) Το `library` είναι αναγνωριστικό ή όνομα βιβλιοθήκης (δείτε {list})
@msg `mode` applies to freeform gradients Το `mode` ισχύει για ντεγκραντέ ελεύθερης μορφής
+@msg a negative `aspect` applies to conic gradients Το αρνητικό `aspect` ισχύει για κωνικά ντεγκραντέ
+@msg `focal` applies to radial gradients Το `focal` ισχύει για ακτινικά ντεγκραντέ
@msg `preserve` must be an object Το `preserve` πρέπει να είναι αντικείμενο
@msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο
@msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5
@@ -4658,7 +4661,7 @@ digits Malayalam Μαλαγιάλαμ
@msg unknown format άγνωστη μορφή
@msg unknown format `{f}` (Save writes {_1}) άγνωστη μορφή `{f}` (η Αποθήκευση υποστηρίζει {_1})
@msg unknown freeform mode `{m}` (points, lines) άγνωστη λειτουργία ελεύθερης μορφής `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) άγνωστος τύπος ντεγκραντέ `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) άγνωστος τύπος ντεγκραντέ `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` άγνωστη επισήμανση `{h}`
@msg unknown option `{path}.{k}` άγνωστη επιλογή `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) άγνωστη επιλογή υπερεκτύπωσης `{o}` (preserve, simulate ή discard)
@@ -5745,7 +5748,7 @@ effect Points Σημεία
@msg span must be "object" or "selection", got {v} το span πρέπει να είναι "object" ή "selection", δόθηκε {v}
@msg span: "selection" places a `gradient`, not a swatch span: το "selection" τοποθετεί ένα `gradient`, όχι δείγμα χρώματος
@msg span: "selection" places a gradient: give `gradient` span: το "selection" τοποθετεί ντεγκραντέ: δώστε `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: το "selection" τοποθετεί γραμμικό ή ακτινικό ντεγκραντέ, όχι ελεύθερης μορφής
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: το "selection" τοποθετεί γραμμικό, ακτινικό ή κωνικό ντεγκραντέ, όχι ελεύθερης μορφής
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: το "selection" τοποθετεί το ίδιο το ντεγκραντέ: παραλείψτε τα `start` και `end` (το τοποθετούν ήδη σε συντεταγμένες εγγράφου)
@msg the Color Picker is not open ο επιλογέας χρώματος δεν είναι ανοιχτός
@msg the Simplify slider is not open ο ρυθμιστής Απλοποίησης δεν είναι ανοιχτός
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 8608f4cb62..bbfc2ca9f9 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2612,6 +2612,7 @@ digits Malayalam Malayálam
Radial Radial
Radial Glows Resplandores radiales
Radial Gradient Degradado radial
+ Conic Gradient Degradado cónico
Radius 1 Radio 1
Radius 2 Radio 2
Radius1 Radio1
@@ -4207,6 +4208,8 @@ axis Both Ambos
@msg `groupColors` must be an array `groupColors` debe ser una lista
@msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list})
@msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre
+@msg a negative `aspect` applies to conic gradients un `aspect` negativo se aplica a los degradados cónicos
+@msg `focal` applies to radial gradients `focal` se aplica a los degradados radiales
@msg `preserve` must be an object `preserve` debe ser un objeto
@msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5
@@ -4709,7 +4712,7 @@ axis Both Ambos
@msg unknown format formato desconocido
@msg unknown format `{f}` (Save writes {_1}) formato desconocido `{f}` (Guardar escribe {_1})
@msg unknown freeform mode `{m}` (points, lines) modo de forma libre desconocido `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) tipo de degradado desconocido `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) tipo de degradado desconocido `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` resaltado desconocido `{h}`
@msg unknown option `{path}.{k}` opción desconocida `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard)
@@ -5722,7 +5725,7 @@ mark-style Japanese Japonesas
@msg span must be "object" or "selection", got {v} span debe ser "object" o "selection", no {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" coloca un `gradient`, no una muestra
@msg span: "selection" places a gradient: give `gradient` span: "selection" coloca un degradado: indique `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" coloca un degradado lineal o radial, no uno de forma libre
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" coloca un degradado lineal, radial o cónico, no uno de forma libre
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" coloca el propio degradado: omita `start` y `end` (ya lo colocan en coordenadas del documento)
Make Image Dynamic Hacer imagen dinámica
Import Data Sets… Importar conjuntos de datos…
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 73655ab13f..868510b228 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -1660,6 +1660,7 @@ digits Malayalam Malajalam
No gradient swatches Ei liukuvärimalleja
Points: clicks add free points Pisteet: napsautukset lisäävät vapaita pisteitä
Radial Gradient Säteittäinen liukuväri
+ Conic Gradient Kartiomainen liukuväri
Reset to White, Black Palauta valkoinen, musta
Reverse Gradient Käännä liukuväri
Save to Swatches Tallenna värimalleihin
@@ -4430,6 +4431,8 @@ imageTrace Outline Ääriviiva
@msg `groupColors` must be an array `groupColors` on oltava taulukko
@msg `library` is a library id or name (see {list}) `library` on kirjaston tunnus tai nimi (ks. {list})
@msg `mode` applies to freeform gradients `mode` koskee vapaamuotoisia liukuvärejä
+@msg a negative `aspect` applies to conic gradients negatiivinen `aspect` koskee kartiomaisia liukuvärejä
+@msg `focal` applies to radial gradients `focal` koskee säteittäisiä liukuvärejä
@msg `preserve` must be an object `preserve` on oltava objekti
@msg `globalAdjust` must be an object `globalAdjust` on oltava objekti
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5
@@ -4919,7 +4922,7 @@ imageTrace Outline Ääriviiva
@msg unknown format tuntematon muoto
@msg unknown format `{f}` (Save writes {_1}) tuntematon muoto `{f}` (Tallenna kirjoittaa muotoja {_1})
@msg unknown freeform mode `{m}` (points, lines) tuntematon vapaamuototila `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) tuntematon liukuvärin laji `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) tuntematon liukuvärin laji `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` tuntematon korostus `{h}`
@msg unknown option `{path}.{k}` tuntematon asetus `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) tuntematon päällepainatusten käsittely `{o}` (preserve, simulate tai discard)
@@ -5757,7 +5760,7 @@ mark-style Japanese Japanilainen
@msg span must be "object" or "selection", got {v} span-arvon on oltava "object" tai "selection", saatiin {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" sijoittaa `gradient`-liukuvärin, ei värimallia
@msg span: "selection" places a gradient: give `gradient` span: "selection" sijoittaa liukuvärin: anna `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" sijoittaa lineaarisen tai säteittäisen liukuvärin, ei vapaamuotoista
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" sijoittaa lineaarisen, säteittäisen tai kartiomaisen liukuvärin, ei vapaamuotoista
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" sijoittaa itse liukuvärin: jätä pois `start` ja `end` (ne sijoittavat sen jo asiakirjan koordinaatteihin)
@msg the Color Picker is not open värinvalitsin ei ole auki
@msg the Simplify slider is not open Yksinkertaista-liukusäädin ei ole auki
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index d9c34fc082..1c078f9261 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2604,6 +2604,7 @@ digits Malayalam Malayalam
Radial Radial
Radial Glows Halos radiaux
Radial Gradient Dégradé radial
+ Conic Gradient Dégradé conique
Radius 1 Rayon 1
Radius 2 Rayon 2
Radius1 Rayon1
@@ -4196,6 +4197,8 @@ axis Both Les deux
@msg `groupColors` must be an array `groupColors` doit être un tableau
@msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list})
@msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre
+@msg a negative `aspect` applies to conic gradients un `aspect` négatif s’applique aux dégradés coniques
+@msg `focal` applies to radial gradients `focal` s’applique aux dégradés radiaux
@msg `preserve` must be an object `preserve` doit être un objet
@msg `globalAdjust` must be an object `globalAdjust` doit être un objet
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5
@@ -4698,7 +4701,7 @@ axis Both Les deux
@msg unknown format format inconnu
@msg unknown format `{f}` (Save writes {_1}) format inconnu `{f}` (Enregistrer écrit {_1})
@msg unknown freeform mode `{m}` (points, lines) mode de forme libre inconnu `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) type de dégradé inconnu `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) type de dégradé inconnu `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` mise en évidence inconnue `{h}`
@msg unknown option `{path}.{k}` option inconnue `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) surimpressions inconnues `{o}` (preserve, simulate ou discard)
@@ -5775,7 +5778,7 @@ theme Appearance Apparence
@msg span must be "object" or "selection", got {v} span doit être "object" ou "selection", pas {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" place un `gradient`, pas une nuance
@msg span: "selection" places a gradient: give `gradient` span: "selection" place un dégradé : indiquez `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" place un dégradé linéaire ou radial, pas un dégradé de forme libre
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" place un dégradé linéaire, radial ou conique, pas un dégradé de forme libre
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" place le dégradé lui-même : omettez `start` et `end` (ils le placent déjà en coordonnées du document)
Make Image Dynamic Rendre l'image dynamique
Import Data Sets… Importer des jeux de données…
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index d0ab19acf3..131848a80a 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -3555,6 +3555,7 @@ antiAlias Sharp शार्प
Radial Dividers: रेडियल विभाजक:
Radial Glows रेडियल चमक
Radial Gradient रेडियल ग्रेडिएंट
+ Conic Gradient शंक्वाकार ग्रेडिएंट
Radial Repeat रेडियल दोहराव
Radius त्रिज्या
Radius 1 त्रिज्या 1
@@ -5782,6 +5783,8 @@ antiAlias Sharp शार्प
@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}°) से अधिक नहीं हो सकता है।
@msg `mode` applies to freeform gradients `mode` फ़्रीफ़ॉर्म ग्रेडिएंट्स पर लागू होता है
+@msg a negative `aspect` applies to conic gradients ऋणात्मक `aspect` शंक्वाकार ग्रेडिएंट्स पर लागू होता है
+@msg `focal` applies to radial gradients `focal` रेडियल ग्रेडिएंट्स पर लागू होता है
@msg `mode` must be "free", "keepAngles" or "symmetric" `mode` को "free", "keepAngles" या "symmetric" होना चाहिए
@msg `mode` must be "handles", "straight" or "smooth" `mode` को "handles", "straight" या "smooth" होना चाहिए
@msg `mode` must be "straight", "bezier" or "arc" `mode` को "straight", "bezier" या "arc" होना चाहिए
@@ -7461,7 +7464,7 @@ antiAlias Sharp शार्प
@msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला
@msg span: "selection" places a `gradient`, not a swatch span: "selection" एक `gradient` रखता है, स्वॉच नहीं
@msg span: "selection" places a gradient: give `gradient` span: "selection" ग्रेडिएंट रखता है: `gradient` दें
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, फ्रीफॉर्म ग्रेडिएंट नहीं
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" एक रैखिक, रेडियल या शंक्वाकार ग्रेडिएंट रखता है, फ्रीफॉर्म ग्रेडिएंट नहीं
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" खुद ग्रेडिएंट रखता है: `start` और `end` न दें (वे उसे डॉक्यूमेंट निर्देशांकों पर रख देते हैं)
@msg speed must be accelerated, step or pause speed: गति को तेज, कदम या रोकें होना चाहिए
@msg Split Into Grid: select paths large enough for the grid स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें
@@ -7880,7 +7883,7 @@ antiAlias Sharp शार्प
@msg unknown format `{f}` (vcswatches, gpl, ase or css) अज्ञात फ़ॉर्मैट `{f}` (vcswatches, gpl, ase या css)
@msg unknown freeform mode `{m}` (points, lines) अज्ञात फ्रीफॉर्म मोड `{m}` (बिंदुओं, लाइनों)
@msg unknown from {f} {f} से अज्ञात
-@msg unknown gradient kind `{k}` (linear, radial, freeform) अज्ञात ग्रेडिएंट `{k}` (रैखिक, रेडियल, फ्रीफॉर्म)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) अज्ञात ग्रेडिएंट `{k}` (रैखिक, रेडियल, शंक्वाकार, फ्रीफॉर्म)
@msg unknown graph type `{t}` अज्ञात ग्राफ प्रकार `{t}`
@msg unknown grid colours `{name}` (one of {_1}) अज्ञात ग्रिड रंग `{name}` ({_1} में से एक)
@msg unknown gridType `{t}` अज्ञात gridType `{t}`
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 2adab8bf2b..65689ceb69 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -3161,6 +3161,7 @@ mark-style Japanese Japán
Radial Blur… Körkörös elmosás…
Radial Glows Körkörös ragyogások
Radial Gradient Körkörös színátmenet
+ Conic Gradient Kúpos színátmenet
Radial Repeat Körkörös ismétlés
Radius Sugár
Radius 1 Sugár 1
@@ -4715,6 +4716,8 @@ imageTrace Outline Körvonal
@msg `colors` must be an array of colors, not {v} `colors`: színek tömbje kell, ez nem az: {v}
@msg `library` is a library id or name (see {list}) a `library` könyvtárazonosító vagy -név lehet (lásd: {list})
@msg `mode` applies to freeform gradients a `mode` a szabad formájú színátmenetekre vonatkozik
+@msg a negative `aspect` applies to conic gradients a negatív `aspect` a kúpos színátmenetekre vonatkozik
+@msg `focal` applies to radial gradients a `focal` a sugaras színátmenetekre vonatkozik
@msg `preserve` must be an object `preserve`: objektum kell
@msg `globalAdjust` must be an object `globalAdjust`: objektum kell
@msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell
@@ -5522,7 +5525,7 @@ imageTrace Outline Körvonal
@msg unknown format ismeretlen formátum
@msg unknown format `{f}` (Save writes {_1}) ismeretlen formátum: `{f}` (a Mentés ezeket írja: {_1})
@msg unknown freeform mode `{m}` (points, lines) ismeretlen szabad formájú mód: `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) ismeretlen színátmenet-fajta: `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) ismeretlen színátmenet-fajta: `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` ismeretlen kiemelés: `{h}`
@msg unknown option `{path}.{k}` ismeretlen beállítás: `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) ismeretlen felülnyomás-kezelés: `{o}` (preserve, simulate vagy discard)
@@ -5763,7 +5766,7 @@ imageTrace Outline Körvonal
@msg span must be "object" or "selection", got {v} a span értéke "object" vagy "selection" lehet, nem {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" egy `gradient` értéket helyez el, nem színmintát
@msg span: "selection" places a gradient: give `gradient` span: "selection" színátmenetet helyez el: adja meg a `gradient` értéket
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" lineáris vagy sugaras színátmenetet helyez el, nem szabad formájút
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" lineáris, sugaras vagy kúpos színátmenetet helyez el, nem szabad formájút
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" magát a színátmenetet helyezi el: hagyja el a `start` és `end` értéket (ezek már a dokumentum koordinátáiban helyezik el)
Make Image Dynamic Kép dinamikussá tétele
Import Data Sets… Adatkészletek importálása…
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index dd087c5375..13235ee60f 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -1598,6 +1598,7 @@ digits Malayalam Malayalam
No gradient swatches Tidak ada swatch gradien
Points: clicks add free points Titik: klik menambahkan titik bebas
Radial Gradient Gradien radial
+ Conic Gradient Gradien kerucut
Reset to White, Black Setel ulang ke putih, hitam
Reverse Gradient Balikkan gradien
Save to Swatches Simpan ke swatch
@@ -4192,6 +4193,8 @@ imageTrace Outline Kontur
@msg `gradient` must be an object `gradient` harus berupa objek
@msg `library` is a library id or name (see {list}) `library` adalah id atau nama pustaka (lihat {list})
@msg `mode` applies to freeform gradients `mode` berlaku untuk gradien bentuk bebas
+@msg a negative `aspect` applies to conic gradients `aspect` negatif berlaku untuk gradien kerucut
+@msg `focal` applies to radial gradients `focal` berlaku untuk gradien radial
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` harus 1, 3, atau 5
@msg `stops` must be an array `stops` harus berupa larik
@msg `svg` must be an object of SVG options (see document.formats) `svg` harus berupa objek opsi SVG (lihat document.formats)
@@ -4627,7 +4630,7 @@ imageTrace Outline Kontur
@msg unknown format format tidak dikenal
@msg unknown format `{f}` (Save writes {_1}) format `{f}` tidak dikenal (Simpan menulis {_1})
@msg unknown freeform mode `{m}` (points, lines) mode bentuk bebas `{m}` tidak dikenal (titik, garis)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, bentuk bebas)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, kerucut, bentuk bebas)
@msg unknown highlight `{h}` sorotan `{h}` tidak dikenal
@msg unknown option `{path}.{k}` opsi `{path}.{k}` tidak dikenal
@msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (pertahankan, simulasikan, atau buang)
@@ -5380,7 +5383,7 @@ mark-style Japanese Jepang
@msg span must be "object" or "selection", got {v} span harus "object" atau "selection", diterima {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" menempatkan `gradient`, bukan swatch
@msg span: "selection" places a gradient: give `gradient` span: "selection" menempatkan gradien: berikan `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" menempatkan gradien linear atau radial, bukan gradien bebas
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" menempatkan gradien linear, radial, atau kerucut, bukan gradien bebas
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" menempatkan gradien itu sendiri: jangan sertakan `start` dan `end` (keduanya sudah menempatkannya dalam koordinat dokumen)
Make Image Dynamic Jadikan gambar dinamis
Import Data Sets… Impor himpunan data…
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index ddb7e84e64..2d7912b444 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2646,6 +2646,7 @@ digits Malayalam Malayalam
Radial Radiale
Radial Glows Bagliori radiali
Radial Gradient Sfumatura radiale
+ Conic Gradient Sfumatura conica
Radius 1 Raggio 1
Radius 2 Raggio 2
Radius1 Raggio1
@@ -4250,6 +4251,8 @@ axis Both Entrambi
@msg `groupColors` must be an array `groupColors` deve essere un array
@msg `library` is a library id or name (see {list}) `library` è l'id o il nome di una libreria (vedi {list})
@msg `mode` applies to freeform gradients `mode` si applica alle sfumature a forma libera
+@msg a negative `aspect` applies to conic gradients un `aspect` negativo si applica alle sfumature coniche
+@msg `focal` applies to radial gradients `focal` si applica alle sfumature radiali
@msg `preserve` must be an object `preserve` deve essere un oggetto
@msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5
@@ -4752,7 +4755,7 @@ axis Both Entrambi
@msg unknown format formato sconosciuto
@msg unknown format `{f}` (Save writes {_1}) formato sconosciuto `{f}` (Salva scrive {_1})
@msg unknown freeform mode `{m}` (points, lines) modalità a forma libera sconosciuta `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) tipo di sfumatura sconosciuto `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) tipo di sfumatura sconosciuto `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` evidenziazione sconosciuta `{h}`
@msg unknown option `{path}.{k}` opzione sconosciuta `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) valore di sovrastampa sconosciuto `{o}` (preserve, simulate o discard)
@@ -5393,7 +5396,7 @@ lighting Light Luce
@msg span must be "object" or "selection", got {v} span deve essere "object" o "selection", non {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" posiziona un `gradient`, non un campione
@msg span: "selection" places a gradient: give `gradient` span: "selection" posiziona una sfumatura: indica `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" posiziona una sfumatura lineare o radiale, non una sfumatura a forma libera
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" posiziona una sfumatura lineare, radiale o conica, non una sfumatura a forma libera
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" posiziona la sfumatura stessa: ometti `start` e `end` (la posizionano già in coordinate del documento)
Make Image Dynamic Rendi immagine dinamica
Import Data Sets… Importa set di dati…
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index ab3af79a44..c36c82877a 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2687,6 +2687,7 @@ mark-style Japanese 日本式
RECENT 最近使用したもの
Radar Graph Tool レーダーチャートツール
Radial Gradient 円形グラデーション
+ Conic Gradient 円錐グラデーション
Random ランダム
Randomize Colors カラーをランダム化
Randomly change color order カラーの順序をランダムに変更
@@ -4349,6 +4350,8 @@ swatch Bright Violet ビビッドバイオレット
@msg `gradient` must be an object `gradient` にはオブジェクトを指定してください
@msg `groupColors` must be an array `groupColors` には配列を指定してください
@msg `mode` applies to freeform gradients `mode` はフリーフォームグラデーションにのみ適用されます
+@msg a negative `aspect` applies to conic gradients 負の `aspect` は円錐グラデーションにのみ適用されます
+@msg `focal` applies to radial gradients `focal` は円形グラデーションにのみ適用されます
@msg `preserve` must be an object `preserve` にはオブジェクトを指定してください
@msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください
@msg `stops` must be an array `stops` には配列を指定してください
@@ -4358,7 +4361,7 @@ swatch Bright Violet ビビッドバイオレット
@msg stop {i} needs `offset` 分岐点 {i} には `offset` が必要です
@msg stop {i} needs a valid `color` 分岐点 {i} には有効な `color` が必要です
@msg stop {i}: `{k}` must be a number 分岐点 {i}: `{k}` には数値を指定してください
-@msg unknown gradient kind `{k}` (linear, radial, freeform) `{k}` は不明なグラデーションの種類です (linear、radial、freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) `{k}` は不明なグラデーションの種類です (linear、radial、conic、freeform)
@msg the art could not be sampled アートをサンプリングできませんでした
@msg the art is too light for any dots アートが明るすぎて網点を生成できません
@msg the document has no symbols ドキュメントにシンボルがありません
@@ -5436,7 +5439,7 @@ lighting Light ライト
@msg span must be "object" or "selection", got {v} span は "object" または "selection" で指定してください ({v} は指定できません)
@msg span: "selection" places a `gradient`, not a swatch span: "selection" は `gradient` を配置します。スウォッチは配置できません
@msg span: "selection" places a gradient: give `gradient` span: "selection" はグラデーションを配置します: `gradient` を指定してください
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" は線形または円形グラデーションを配置します。フリーグラデーションは配置できません
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" は線形、円形または円錐グラデーションを配置します。フリーグラデーションは配置できません
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" はグラデーション自体を配置します: `start` と `end` は省略してください (これらはすでにドキュメント座標で配置を指定します)
Make Image Dynamic 画像を動的にする
Import Data Sets… データセットを読み込み…
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index 013326ed60..62774834cb 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -1532,6 +1532,7 @@ digits Malayalam მალაიალამური
No gradient swatches გრადიენტის ნიმუშები არ არის
Points: clicks add free points წერტილები: დაწკაპუნებები ამატებს თავისუფალ წერტილებს
Radial Gradient რადიალური გრადიენტი
+ Conic Gradient კონუსური გრადიენტი
Reset to White, Black თეთრზე და შავზე დაბრუნება
Reverse Gradient გრადიენტის შებრუნება
Gradient Presets გრადიენტის პრესეტები
@@ -3989,6 +3990,8 @@ font-technology Variable ცვლადი
@msg `groupColors` must be an array `groupColors` უნდა იყოს მასივი
@msg `library` is a library id or name (see {list}) `library` არის ბიბლიოთეკის id ან სახელი (იხ. {list})
@msg `mode` applies to freeform gradients `mode` ეხება თავისუფალი ფორმის გრადიენტებს
+@msg a negative `aspect` applies to conic gradients უარყოფითი `aspect` ეხება კონუსურ გრადიენტებს
+@msg `focal` applies to radial gradients `focal` ეხება რადიალურ გრადიენტებს
@msg `preserve` must be an object `preserve` უნდა იყოს ობიექტი
@msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5
@@ -4454,7 +4457,7 @@ font-technology Variable ცვლადი
@msg unknown format უცნობი ფორმატი
@msg unknown format `{f}` (Save writes {_1}) უცნობი ფორმატი `{f}` (შენახვა წერს: {_1})
@msg unknown freeform mode `{m}` (points, lines) უცნობი თავისუფალი რეჟიმი `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) უცნობი გრადიენტის სახე `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) უცნობი გრადიენტის სახე `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` უცნობი გამოკვეთა `{h}`
@msg unknown option `{path}.{k}` უცნობი პარამეტრი `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{o}` (preserve, simulate ან discard)
@@ -5444,7 +5447,7 @@ imageTrace Outline კონტური
@msg span must be "object" or "selection", got {v} span უნდა იყოს "object" ან "selection", მიღებულია {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" ათავსებს `gradient`-ს და არა ფერის ნიმუშს
@msg span: "selection" places a gradient: give `gradient` span: "selection" ათავსებს გრადიენტს: მიუთითეთ `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" ათავსებს წრფივ ან რადიალურ გრადიენტს და არა თავისუფალი ფორმისას
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" ათავსებს წრფივ, რადიალურ ან კონუსურ გრადიენტს და არა თავისუფალი ფორმისას
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" თავად ათავსებს გრადიენტს: გამოტოვეთ `start` და `end` (ისინი მას უკვე დოკუმენტის კოორდინატებში ათავსებენ)
Make Image Dynamic გამოსახულების დინამიკურად ქცევა
Import Data Sets… მონაცემთა ნაკრებების იმპორტი…
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 9a86073544..7fbd5461a9 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -2677,6 +2677,7 @@ mark-style Japanese 일본식
RECENT 최근 항목
Radar Graph Tool 레이더 그래프 도구
Radial Gradient 방사형 그레이디언트
+ Conic Gradient 원뿔형 그레이디언트
Random 무작위
Randomize Colors 색상 무작위 변경
Randomly change color order 색상 순서를 무작위로 변경
@@ -4276,6 +4277,8 @@ theme Appearance 외관
@msg `gradient` must be an object `gradient`는 개체여야 합니다
@msg `groupColors` must be an array `groupColors`는 배열이어야 합니다
@msg `mode` applies to freeform gradients `mode`는 자유형 그레이디언트에 적용됩니다
+@msg a negative `aspect` applies to conic gradients 음수 `aspect`는 원뿔형 그레이디언트에 적용됩니다
+@msg `focal` applies to radial gradients `focal`은 방사형 그레이디언트에 적용됩니다
@msg `preserve` must be an object `preserve`는 개체여야 합니다
@msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다
@msg `stops` must be an array `stops`는 배열이어야 합니다
@@ -4285,7 +4288,7 @@ theme Appearance 외관
@msg stop {i} needs `offset` 정지점 {i}에는 `offset`이 필요합니다
@msg stop {i} needs a valid `color` 정지점 {i}에는 올바른 `color`가 필요합니다
@msg stop {i}: `{k}` must be a number 정지점 {i}: `{k}`은(는) 숫자여야 합니다
-@msg unknown gradient kind `{k}` (linear, radial, freeform) 알 수 없는 그레이디언트 종류 `{k}`(linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) 알 수 없는 그레이디언트 종류 `{k}`(linear, radial, conic, freeform)
@msg the art could not be sampled 아트웍을 샘플링할 수 없습니다
@msg the art is too light for any dots 아트웍이 너무 밝아 점을 만들 수 없습니다
@msg the document has no symbols 문서에 심볼이 없습니다
@@ -5354,7 +5357,7 @@ lighting Light 광원
@msg span must be "object" or "selection", got {v} span은 "object" 또는 "selection"이어야 합니다. {v}은(는) 사용할 수 없습니다
@msg span: "selection" places a `gradient`, not a swatch span: "selection"은 `gradient`를 배치합니다. 견본은 배치할 수 없습니다
@msg span: "selection" places a gradient: give `gradient` span: "selection"은 그레이디언트를 배치합니다: `gradient`를 지정하세요
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection"은 선형 또는 방사형 그레이디언트를 배치합니다. 자유형 그레이디언트는 배치할 수 없습니다
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection"은 선형, 방사형 또는 원뿔형 그레이디언트를 배치합니다. 자유형 그레이디언트는 배치할 수 없습니다
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection"은 그레이디언트 자체를 배치합니다: `start`와 `end`를 빼세요 (이미 문서 좌표로 배치합니다)
Make Image Dynamic 이미지를 동적으로 만들기
Import Data Sets… 데이터 세트 가져오기…
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index 89bfa34fde..2c4edf59fc 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -1612,6 +1612,7 @@ digits Malayalam Malayalam
No gradient swatches Geen verloopstalen
Points: clicks add free points Punten: klikken voegen vrije punten toe
Radial Gradient Radiaal verloop
+ Conic Gradient Conisch verloop
Reset to White, Black Herstellen naar wit, zwart
Reverse Gradient Verloop omkeren
Save to Swatches Opslaan in stalen
@@ -4206,6 +4207,8 @@ imageTrace Outline Contour
@msg `gradient` must be an object `gradient` moet een object zijn
@msg `library` is a library id or name (see {list}) `library` is een bibliotheek-id of -naam (zie {list})
@msg `mode` applies to freeform gradients `mode` geldt voor verlopen met vrije vorm
+@msg a negative `aspect` applies to conic gradients een negatieve `aspect` geldt voor conische verlopen
+@msg `focal` applies to radial gradients `focal` geldt voor radiale verlopen
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` moet 1, 3 of 5 zijn
@msg `stops` must be an array `stops` moet een array zijn
@msg `svg` must be an object of SVG options (see document.formats) `svg` moet een object met SVG-opties zijn (zie document.formats)
@@ -4639,7 +4642,7 @@ imageTrace Outline Contour
@msg unknown format onbekende indeling
@msg unknown format `{f}` (Save writes {_1}) onbekende indeling `{f}` (Opslaan schrijft {_1})
@msg unknown freeform mode `{m}` (points, lines) onbekende vrijvormmodus `{m}` (punten, lijnen)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) onbekend verlooptype `{k}` (lineair, radiaal, vrijvorm)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) onbekend verlooptype `{k}` (lineair, radiaal, conisch, vrijvorm)
@msg unknown highlight `{h}` onbekende markering `{h}`
@msg unknown option `{path}.{k}` onbekende optie `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (behouden, simuleren of verwerpen)
@@ -5392,7 +5395,7 @@ mark-style Japanese Japans
@msg span must be "object" or "selection", got {v} span moet "object" of "selection" zijn, maar is {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" plaatst een `gradient`, geen staal
@msg span: "selection" places a gradient: give `gradient` span: "selection" plaatst een verloop: geef `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" plaatst een lineair of radiaal verloop, geen vrij verloop
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" plaatst een lineair, radiaal of conisch verloop, geen vrij verloop
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" plaatst het verloop zelf: laat `start` en `end` weg (die plaatsen het al in documentcoördinaten)
Make Image Dynamic Afbeelding dynamisch maken
Import Data Sets… Gegevenssets importeren…
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index f38f09cd67..ce5d79e8fb 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -1599,6 +1599,7 @@ digits Malayalam Malajalam
No gradient swatches Brak próbek gradientów
Points: clicks add free points Punkty: kliknięcia dodają wolne punkty
Radial Gradient Gradient promienisty
+ Conic Gradient Gradient stożkowy
Reset to White, Black Resetuj do bieli i czerni
Reverse Gradient Odwróć gradient
Save to Swatches Zapisz w próbkach
@@ -4339,6 +4340,8 @@ imageTrace Outline Kontur
@msg `groupColors` must be an array `groupColors` musi być tablicą
@msg `library` is a library id or name (see {list}) `library` to identyfikator lub nazwa biblioteki (patrz {list})
@msg `mode` applies to freeform gradients `mode` dotyczy gradientów dowolnych
+@msg a negative `aspect` applies to conic gradients ujemny `aspect` dotyczy gradientów stożkowych
+@msg `focal` applies to radial gradients `focal` dotyczy gradientów radialnych
@msg `preserve` must be an object `preserve` musi być obiektem
@msg `globalAdjust` must be an object `globalAdjust` musi być obiektem
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5
@@ -4843,7 +4846,7 @@ imageTrace Outline Kontur
@msg unknown format nieznany format
@msg unknown format `{f}` (Save writes {_1}) nieznany format `{f}` (zapis tworzy {_1})
@msg unknown freeform mode `{m}` (points, lines) nieznany tryb dowolny `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) nieznany rodzaj gradientu `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) nieznany rodzaj gradientu `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` nieznane wyróżnienie `{h}`
@msg unknown option `{path}.{k}` nieznana opcja `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) nieznana wartość nadruków `{o}` (preserve, simulate lub discard)
@@ -5633,7 +5636,7 @@ mark-style Japanese Japońskie
@msg span must be "object" or "selection", got {v} span musi mieć wartość "object" lub "selection", a nie {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" umieszcza `gradient`, a nie próbkę
@msg span: "selection" places a gradient: give `gradient` span: "selection" umieszcza gradient: podaj `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" umieszcza gradient liniowy lub radialny, a nie dowolny
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" umieszcza gradient liniowy, radialny lub stożkowy, a nie dowolny
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" umieszcza sam gradient: pomiń `start` i `end` (one już umieszczają go we współrzędnych dokumentu)
Make Image Dynamic Uczyń obraz dynamicznym
Import Data Sets… Importuj zestawy danych…
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index d23037e024..0e062364be 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2177,6 +2177,7 @@ digits Malayalam Malaiala
Radial Radial
Radial Glows Brilhos Radiais
Radial Gradient Gradiente Radial
+ Conic Gradient Gradiente Cônico
Radius: Raio:
Rainbow Arco-íris
Random Aleatório
@@ -3759,6 +3760,8 @@ axis Both Ambos
@msg `groupColors` must be an array `groupColors` deve ser uma matriz
@msg `library` is a library id or name (see {list}) `library` é o id ou o nome de uma biblioteca (veja {list})
@msg `mode` applies to freeform gradients `mode` se aplica a gradientes de forma livre
+@msg a negative `aspect` applies to conic gradients um `aspect` negativo se aplica a gradientes cônicos
+@msg `focal` applies to radial gradients `focal` se aplica a gradientes radiais
@msg `preserve` must be an object `preserve` deve ser um objeto
@msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5
@@ -4251,7 +4254,7 @@ axis Both Ambos
@msg unknown format formato desconhecido
@msg unknown format `{f}` (Save writes {_1}) formato desconhecido `{f}` (Salvar grava {_1})
@msg unknown freeform mode `{m}` (points, lines) modo de forma livre desconhecido `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) tipo de gradiente desconhecido `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) tipo de gradiente desconhecido `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` destaque desconhecido `{h}`
@msg unknown option `{path}.{k}` opção desconhecida `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressões desconhecidas `{o}` (preserve, simulate ou discard)
@@ -5663,7 +5666,7 @@ mark-style Japanese Japonesas
@msg span must be "object" or "selection", got {v} span deve ser "object" ou "selection", não {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" posiciona um `gradient`, não uma amostra
@msg span: "selection" places a gradient: give `gradient` span: "selection" posiciona um degradê: informe `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" posiciona um degradê linear ou radial, não um de forma livre
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" posiciona um degradê linear, radial ou cônico, não um de forma livre
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" posiciona o próprio degradê: omita `start` e `end` (eles já o posicionam em coordenadas do documento)
Make Image Dynamic Tornar imagem dinâmica
@msg Batch: {count} processed Lote: {count} processados
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 05cdfb8a65..2708d6110d 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2641,6 +2641,7 @@ digits Malayalam Малаялам
Radial Радиальный
Radial Glows Радиальные свечения
Radial Gradient Радиальный градиент
+ Conic Gradient Конический градиент
Radius 1 Радиус 1
Radius 2 Радиус 2
Radius1 Радиус1
@@ -4244,6 +4245,8 @@ axis Both Оба
@msg `groupColors` must be an array `groupColors` должно быть массивом
@msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list})
@msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам
+@msg a negative `aspect` applies to conic gradients отрицательный `aspect` применяется к коническим градиентам
+@msg `focal` applies to radial gradients `focal` применяется к радиальным градиентам
@msg `preserve` must be an object `preserve` должно быть объектом
@msg `globalAdjust` must be an object `globalAdjust` должно быть объектом
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5
@@ -4746,7 +4749,7 @@ axis Both Оба
@msg unknown format неизвестный формат
@msg unknown format `{f}` (Save writes {_1}) неизвестный формат `{f}` (сохранение записывает {_1})
@msg unknown freeform mode `{m}` (points, lines) неизвестный режим произвольной формы `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) неизвестный тип градиента `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) неизвестный тип градиента `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` неизвестное выделение `{h}`
@msg unknown option `{path}.{k}` неизвестный параметр `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) неизвестные наложения `{o}` (preserve, simulate или discard)
@@ -5387,7 +5390,7 @@ lighting Light Свет
@msg span must be "object" or "selection", got {v} span должен быть "object" или "selection", а не {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" размещает `gradient`, а не образец
@msg span: "selection" places a gradient: give `gradient` span: "selection" размещает градиент: укажите `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" размещает линейный или радиальный градиент, а не произвольный
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" размещает линейный, радиальный или конический градиент, а не произвольный
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" сам размещает градиент: уберите `start` и `end` (они уже задают его в координатах документа)
Make Image Dynamic Сделать изображение динамическим
Import Data Sets… Импортировать наборы данных…
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index 8a46c3e7c5..cffd563dbb 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -2549,6 +2549,7 @@ digits Malayalam Malayalam
Radial Radiell
Radial Glows Radiellt sken
Radial Gradient Radiell gradient
+ Conic Gradient Konisk gradient
Radius 1 Radie 1
Radius 2 Radie 2
Radius1 Radie1
@@ -4117,6 +4118,8 @@ axis Both Båda
@msg `groupColors` must be an array `groupColors` måste vara en array
@msg `library` is a library id or name (see {list}) `library` är ett biblioteks-id eller biblioteksnamn (se {list})
@msg `mode` applies to freeform gradients `mode` gäller för fria gradienter
+@msg a negative `aspect` applies to conic gradients ett negativt `aspect` gäller för koniska gradienter
+@msg `focal` applies to radial gradients `focal` gäller för radiella gradienter
@msg `preserve` must be an object `preserve` måste vara ett objekt
@msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5
@@ -4607,7 +4610,7 @@ axis Both Båda
@msg unknown format okänt format
@msg unknown format `{f}` (Save writes {_1}) okänt format `{f}` (Spara skriver {_1})
@msg unknown freeform mode `{m}` (points, lines) okänt fritt läge `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) okänd gradienttyp `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) okänd gradienttyp `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` okänd högdager `{h}`
@msg unknown option `{path}.{k}` okänt alternativ `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) okänt värde för övertryck `{o}` (preserve, simulate eller discard)
@@ -5749,7 +5752,7 @@ theme Appearance Utseende
@msg span must be "object" or "selection", got {v} span måste vara "object" eller "selection", fick {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" placerar en `gradient`, inte en färgruta
@msg span: "selection" places a gradient: give `gradient` span: "selection" placerar en gradient: ange `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" placerar en linjär eller radiell gradient, inte en fri
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" placerar en linjär, radiell eller konisk gradient, inte en fri
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" placerar själva gradienten: utelämna `start` och `end` (de placerar den redan i dokumentkoordinater)
@msg the Color Picker is not open färgväljaren är inte öppen
@msg the Simplify slider is not open Förenkla-reglaget är inte öppet
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
index 7a8d0c83bd..63512201d2 100644
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -2507,7 +2507,7 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[
("scale must be a number from {_1} to {_2}, not {_3}", &["scale"]),
("scale must be a number from {_1} to {_2}, not {s}", &["scale"]),
("show must be true or false", &["show"]),
- ("span: \"selection\" places a linear or radial gradient, not a freeform one", &["span"]),
+ ("span: \"selection\" places a linear, radial or conic gradient, not a freeform one", &["span"]),
("thickness must be from 0.01 to 5000 pt", &["thickness"]),
("title can't be empty", &["title"]),
("view is required", &["view"]),
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index ad9140d291..a978bc3285 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -2421,6 +2421,7 @@ digits Malayalam Malayalam
Radial Radyal
Radial Glows Radyal Parıltılar
Radial Gradient Radyal Gradyan
+ Conic Gradient Konik Gradyan
Radius 1 Yarıçap 1
Radius 2 Yarıçap 2
Radius1 Yarıçap1
@@ -3997,6 +3998,8 @@ axis Both Her İkisi
@msg `groupColors` must be an array `groupColors` bir dizi olmalıdır
@msg `library` is a library id or name (see {list}) `library` bir kitaplık kimliği veya adıdır (bkz. {list})
@msg `mode` applies to freeform gradients `mode` serbest biçimli degradeler için geçerlidir
+@msg a negative `aspect` applies to conic gradients negatif `aspect` konik degradeler için geçerlidir
+@msg `focal` applies to radial gradients `focal` radyal degradeler için geçerlidir
@msg `preserve` must be an object `preserve` bir nesne olmalıdır
@msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır
@@ -4456,7 +4459,7 @@ axis Both Her İkisi
@msg unknown format bilinmeyen format
@msg unknown format `{f}` (Save writes {_1}) bilinmeyen format `{f}` (Kaydet, {_1} yazar)
@msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (noktalar, çizgiler)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, serbest biçimli)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, konik, serbest biçimli)
@msg unknown highlight `{h}` bilinmeyen vurgu `{h}`
@msg unknown option `{path}.{k}` bilinmeyen seçenek `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at)
@@ -5456,7 +5459,7 @@ theme Appearance Görünüm
@msg span must be "object" or "selection", got {v} span "object" veya "selection" olmalıdır, {v} değil
@msg span: "selection" places a `gradient`, not a swatch span: "selection" bir renk örneği değil, bir `gradient` yerleştirir
@msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: `gradient` verin
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" serbest biçimli bir degrade değil, doğrusal, radyal veya konik bir degrade yerleştirir
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" degradenin kendisini yerleştirir: `start` ve `end` değerlerini vermeyin (bunlar degradeyi zaten belge koordinatlarına yerleştirir)
Make Image Dynamic Görüntüyü Dinamik Hale Getirin
Import Data Sets… Veri Kümelerini İçe Aktar…
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 1d687f8a56..8c988b4d75 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2638,6 +2638,7 @@ digits Malayalam Малаялам
Radial Радіальний
Radial Glows Радіальні світіння
Radial Gradient Радіальний градієнт
+ Conic Gradient Конічний градієнт
Radius 1 Радіус 1
Radius 2 Радіус 2
Radius1 Радіус1
@@ -4234,6 +4235,8 @@ axis Both Обидві
@msg `groupColors` must be an array `groupColors` має бути масивом
@msg `library` is a library id or name (see {list}) `library` — це ідентифікатор або ім’я бібліотеки (див. {list})
@msg `mode` applies to freeform gradients `mode` застосовується до довільних градієнтів
+@msg a negative `aspect` applies to conic gradients від’ємний `aspect` застосовується до конічних градієнтів
+@msg `focal` applies to radial gradients `focal` застосовується до радіальних градієнтів
@msg `preserve` must be an object `preserve` має бути об’єктом
@msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5
@@ -4736,7 +4739,7 @@ axis Both Обидві
@msg unknown format невідомий формат
@msg unknown format `{f}` (Save writes {_1}) невідомий формат `{f}` (збереження записує {_1})
@msg unknown freeform mode `{m}` (points, lines) невідомий режим довільної форми `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) невідомий тип градієнта `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) невідомий тип градієнта `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` невідоме виділення `{h}`
@msg unknown option `{path}.{k}` невідомий параметр `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) невідомі накладення `{o}` (preserve, simulate або discard)
@@ -5373,7 +5376,7 @@ lighting Light Світло
@msg span must be "object" or "selection", got {v} span має бути "object" або "selection", а не {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" розміщує `gradient`, а не зразок
@msg span: "selection" places a gradient: give `gradient` span: "selection" розміщує градієнт: вкажіть `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" розміщує лінійний або радіальний градієнт, а не довільний
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" розміщує лінійний, радіальний або конічний градієнт, а не довільний
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" сам розміщує градієнт: приберіть `start` і `end` (вони вже задають його в координатах документа)
Make Image Dynamic Зробити зображення динамічним
Import Data Sets… Імпортувати набори даних…
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index f12ea3e580..d0b239172d 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2942,6 +2942,7 @@ mark-style Japanese Kiểu Nhật
Radial Dividers: Đường chia hướng tâm:
Radial Glows Ánh sáng hướng tâm
Radial Gradient Chuyển sắc hướng tâm
+ Conic Gradient Chuyển sắc hình nón
Radius Bán kính
Radius 1 Bán kính 1
Radius 2 Bán kính 2
@@ -4368,6 +4369,8 @@ theme Appearance Giao diện
@msg `colors` must be an array of colors, not {v} `colors` phải là mảng màu, không phải {v}
@msg `library` is a library id or name (see {list}) `library` là id hoặc tên thư viện (xem {list})
@msg `mode` applies to freeform gradients `mode` áp dụng cho chuyển sắc tự do
+@msg a negative `aspect` applies to conic gradients `aspect` âm áp dụng cho chuyển sắc hình nón
+@msg `focal` applies to radial gradients `focal` áp dụng cho chuyển sắc xuyên tâm
@msg `preserve` must be an object `preserve` phải là một đối tượng
@msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5
@@ -5182,7 +5185,7 @@ theme Appearance Giao diện
@msg unknown format định dạng không xác định
@msg unknown format `{f}` (Save writes {_1}) định dạng không xác định `{f}` (Lưu ghi {_1})
@msg unknown freeform mode `{m}` (points, lines) chế độ tự do không xác định `{m}` (points, lines)
-@msg unknown gradient kind `{k}` (linear, radial, freeform) loại chuyển sắc không xác định `{k}` (linear, radial, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) loại chuyển sắc không xác định `{k}` (linear, radial, conic, freeform)
@msg unknown highlight `{h}` tô sáng không xác định `{h}`
@msg unknown option `{path}.{k}` tùy chọn không xác định `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) chế độ in đè không xác định `{o}` (preserve, simulate hoặc discard)
@@ -5374,7 +5377,7 @@ theme Appearance Giao diện
@msg span must be "object" or "selection", got {v} span phải là "object" hoặc "selection", không phải {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" đặt một `gradient`, không phải mẫu màu
@msg span: "selection" places a gradient: give `gradient` span: "selection" đặt một dải chuyển màu: hãy cung cấp `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" đặt dải chuyển màu tuyến tính hoặc xuyên tâm, không phải dạng tự do
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" đặt dải chuyển màu tuyến tính, xuyên tâm hoặc hình nón, không phải dạng tự do
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" tự đặt dải chuyển màu: hãy bỏ `start` và `end` (chúng đã đặt nó theo tọa độ tài liệu)
Make Image Dynamic Tạo hình ảnh động
Import Data Sets… Nhập tập dữ liệu…
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index a01e9141bb..644a0cf201 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2514,6 +2514,7 @@ digits Malayalam 马拉雅拉姆数字
Radial 径向
Radial Glows 径向光晕
Radial Gradient 径向渐变
+ Conic Gradient 圆锥渐变
Radius 1 半径 1
Radius 2 半径 2
Radius1 半径1
@@ -4065,6 +4066,8 @@ imageTrace Outline 轮廓
@msg `gradient` must be an object `gradient` 必须是对象
@msg `groupColors` must be an array `groupColors` 必须是数组
@msg `mode` applies to freeform gradients `mode` 仅适用于任意形状渐变
+@msg a negative `aspect` applies to conic gradients 负的 `aspect` 仅适用于圆锥渐变
+@msg `focal` applies to radial gradients `focal` 仅适用于径向渐变
@msg `preserve` must be an object `preserve` 必须是对象
@msg `globalAdjust` must be an object `globalAdjust` 必须是对象
@msg `stops` must be an array `stops` 必须是数组
@@ -4074,7 +4077,7 @@ imageTrace Outline 轮廓
@msg stop {i} needs `offset` 色标 {i} 需要 `offset`
@msg stop {i} needs a valid `color` 色标 {i} 需要有效的 `color`
@msg stop {i}: `{k}` must be a number 色标 {i}:`{k}` 必须是数字
-@msg unknown gradient kind `{k}` (linear, radial, freeform) 未知的渐变类型 `{k}` (linear、radial、freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) 未知的渐变类型 `{k}` (linear、radial、conic、freeform)
@msg the art could not be sampled 无法对图稿取样
@msg the art is too light for any dots 图稿太浅,无法生成任何网点
@msg the document has no symbols 文档中没有符号
@@ -4812,7 +4815,7 @@ imageTrace Outline 轮廓
@msg span must be "object" or "selection", got {v} span 必须是 "object" 或 "selection",实际为 {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而不是色板
@msg span: "selection" places a gradient: give `gradient` span: "selection" 放置渐变:请给出 `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" 放置线性或径向渐变,而不是任意形状渐变
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" 放置线性、径向或圆锥渐变,而不是任意形状渐变
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" 自行放置渐变:请省略 `start` 和 `end` (它们已按文档坐标放置渐变)
@msg Batch: {count} processed 批处理:已处理 {count} 个
@msg Batch: {count} processed, {failed} failed 批处理:已处理 {count} 个,失败 {failed} 个
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 2239397345..5e0dbce0a8 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -1879,6 +1879,8 @@ file-filter Templates 範本
@msg `gradient` must be an object `gradient` 必須是物件
@msg `groupColors` must be an array `groupColors` 必須是陣列
@msg `mode` applies to freeform gradients `mode` 只適用於任意形狀漸層
+@msg a negative `aspect` applies to conic gradients 負的 `aspect` 只適用於圓錐漸層
+@msg `focal` applies to radial gradients `focal` 只適用於放射狀漸層
@msg `preserve` must be an object `preserve` 必須是物件
@msg `globalAdjust` must be an object `globalAdjust` 必須是物件
@msg `stops` must be an array `stops` 必須是陣列
@@ -1888,7 +1890,7 @@ file-filter Templates 範本
@msg stop {i} needs `offset` 色標 {i} 需要 `offset`
@msg stop {i} needs a valid `color` 色標 {i} 需要有效的 `color`
@msg stop {i}: `{k}` must be a number 色標 {i}:`{k}` 必須是數字
-@msg unknown gradient kind `{k}` (linear, radial, freeform) 未知的漸層類型 `{k}` (linear、radial、freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, freeform) 未知的漸層類型 `{k}` (linear、radial、conic、freeform)
@msg the art could not be sampled 無法對圖稿取樣
@msg the art is too light for any dots 圖稿太淡,無法產生任何網點
@msg the document has no symbols 文件中沒有符號
@@ -2625,7 +2627,7 @@ file-filter Templates 範本
@msg span must be "object" or "selection", got {v} span 必須是 "object" 或 "selection",收到 {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而非色票
@msg span: "selection" places a gradient: give `gradient` span: "selection" 會放置漸層:請提供 `gradient`
-@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" 放置線性或放射狀漸層,而非任意形狀漸層
+@msg span: "selection" places a linear, radial or conic gradient, not a freeform one span: "selection" 放置線性、放射狀或圓錐漸層,而非任意形狀漸層
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" 會自行放置漸層:請不要提供 `start` 和 `end` (它們已依文件座標放置漸層)
@msg Batch: {count} processed 批次:已處理 {count} 個
@msg Batch: {count} processed, {failed} failed 批次:已處理 {count} 個,失敗 {failed} 個
@@ -3590,6 +3592,7 @@ digits Malayalam 馬來亞拉姆數字
Radial 放射狀
Radial Glows 放射狀光暈
Radial Gradient 放射狀漸層
+ Conic Gradient 圓錐漸層
Radius 1 半徑 1
Radius 2 半徑 2
Radius1 半徑1
diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs
index 78eb6149d1..08f1769440 100644
--- a/crates/ui-egui/src/icon_data.rs
+++ b/crates/ui-egui/src/icon_data.rs
@@ -89,6 +89,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("dc-free-transform", include_bytes!("../../../assets/icons/dc-free-transform.svg")),
("dc-fx", include_bytes!("../../../assets/icons/dc-fx.svg")),
("dc-gamut", include_bytes!("../../../assets/icons/dc-gamut.svg")),
+ ("dc-grad-conic", include_bytes!("../../../assets/icons/dc-grad-conic.svg")),
("dc-grad-freeform", include_bytes!("../../../assets/icons/dc-grad-freeform.svg")),
("dc-grad-linear", include_bytes!("../../../assets/icons/dc-grad-linear.svg")),
("dc-grad-radial", include_bytes!("../../../assets/icons/dc-grad-radial.svg")),
diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs
index 07744bf1b6..caeded6e83 100644
--- a/crates/ui-egui/src/panels/gradient.rs
+++ b/crates/ui-egui/src/panels/gradient.rs
@@ -124,8 +124,8 @@ fn dragged_stops(origin: &[GradientStop], i: usize, offset: f32, copy: bool, ove
// ---------- shared edits ----------
-/// The Type row: Linear, Radial and Freeform for the paint behind the active proxy (one of them
-/// lit when it is a gradient). The Gradient panel's and the Properties panel's.
+/// The Type row: Linear, Radial, Conic and Freeform for the paint behind the active proxy (one of
+/// them lit when it is a gradient). The Gradient panel's and the Properties panel's.
pub(crate) fn type_row(app: &mut VectorcraftApp, ui: &mut Ui) {
let kind = current(app).map(|g| g.gradient.kind);
ui.horizontal(|ui| {
@@ -133,6 +133,7 @@ pub(crate) fn type_row(app: &mut VectorcraftApp, ui: &mut Ui) {
for (k, icon, tip) in [
(GradientKind::Linear, "dc-grad-linear", tl!("Linear Gradient")),
(GradientKind::Radial, "dc-grad-radial", tl!("Radial Gradient")),
+ (GradientKind::Conic, "dc-grad-conic", tl!("Conic Gradient")),
(GradientKind::Freeform, "dc-grad-freeform", tl!("Freeform Gradient")),
] {
if widgets::icon_button(ui, icon, tip, kind == Some(k), 24.0).clicked() {
@@ -344,17 +345,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
edit(app, json!({"reverse": true}), Live::Released);
}
});
- let radial = kind == GradientKind::Radial && placed;
+ let radial = kind.is_centered() && placed;
ui.horizontal(|ui| {
icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text(tl!("Aspect Ratio"));
- let asp = g.geom.map_or(100.0, |x| (x.aspect * 1000.0).round() / 10.0);
+ // A mirrored conic's negative aspect shows as its size; an edit keeps the mirror.
+ let mirror = g.geom.map_or(1.0, |x| if x.is_mirrored() && kind == GradientKind::Conic { -1.0 } else { 1.0 });
+ let asp = g.geom.map_or(100.0, |x| (x.aspect.abs() * 1000.0).round() / 10.0);
let set = ui
.add_enabled_ui(radial, |ui| {
widgets::spin_plain_named(ui, "grad-aspect", Some(tl!("Aspect Ratio")), asp, "%", 1, 104.0, 1.0, 0.5, &ASPECT_PRESETS)
})
.inner;
if let Some(a) = set {
- edit(app, json!({"aspect": a}), Live::Released);
+ edit(app, json!({"aspect": a * mirror}), Live::Released);
}
});
});
diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs
index 9177f315e2..d70b112729 100644
--- a/crates/ui-egui/src/panels/gradient/tests.rs
+++ b/crates/ui-egui/src/panels/gradient/tests.rs
@@ -354,6 +354,22 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
assert!(texts.iter().any(|(t, _)| t == "0%"));
}
+/// A mirrored conic's Aspect Ratio field shows the extent's size, and a typed value keeps the
+/// sweep mirrored.
+#[test]
+fn the_aspect_field_keeps_a_mirrored_conic_mirrored() {
+ let mut app = app(json!({"gradient": {"kind": "conic", "aspect": -50}}));
+ let mut p = Panel::new();
+ let texts = p.frame(&mut app, vec![], Modifiers::NONE);
+ let field = texts.iter().find(|(t, _)| t == "50%").unwrap_or_else(|| panic!("the aspect field: {texts:?}")).1;
+ p.click(&mut app, field);
+ let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers };
+ p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("80".into())], Modifiers::NONE);
+ p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE);
+ let geom = fill(&app).geom.unwrap();
+ assert!((geom.aspect + 0.8).abs() < 1e-9 && geom.is_mirrored(), "{geom:?}");
+}
+
#[test]
fn the_thumbnail_menu_applies_gradient_swatches_and_saves_one() {
let mut app = app(json!({"color": "#336699"}));
diff --git a/crates/ui-egui/src/panels/swatches/tiles.rs b/crates/ui-egui/src/panels/swatches/tiles.rs
index 7e92f261bf..af08770ebe 100644
--- a/crates/ui-egui/src/panels/swatches/tiles.rs
+++ b/crates/ui-egui/src/panels/swatches/tiles.rs
@@ -141,6 +141,7 @@ pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bo
let icon = match g.gradient.kind {
GradientKind::Linear => "dc-grad-linear",
GradientKind::Radial => "dc-grad-radial",
+ GradientKind::Conic => "dc-grad-conic",
GradientKind::Freeform => "dc-grad-freeform",
};
icons::paint(ui, icon, kind.expand(1.0), t.icon);
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index 2dd361a7d6..d8914ce39f 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -730,6 +730,23 @@ mod gradient_chip_tests {
assert_eq!(at(&up, 0, 2), at(&up, 4, 2));
}
+ /// A conic chip sweeps clockwise from its angle, or counterclockwise when the gradient is
+ /// mirrored.
+ #[test]
+ fn conic_chip_sweeps_the_way_the_gradient_turns() {
+ let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i].r();
+ let mut gp = paint(GradientKind::Conic, 0.0);
+ let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
+ // From the right: a quarter turn on is below the center, halfway around left of it.
+ assert!(at(&chip, 2, 4).abs_diff(191) <= 2 && at(&chip, 2, 0).abs_diff(64) <= 2, "{chip:?}");
+ assert!(at(&chip, 0, 2).abs_diff(128) <= 2, "{chip:?}");
+ let c = vectorcraft_geom::kurbo::Point::new(50.0, 50.0);
+ gp.geom =
+ Some(vectorcraft_color::GradientGeom { start: c, end: c + vectorcraft_geom::kurbo::Vec2::new(10.0, 0.0), aspect: -1.0, focal: None });
+ let mirrored = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
+ assert!(at(&mirrored, 2, 4).abs_diff(64) <= 2 && at(&mirrored, 2, 0).abs_diff(191) <= 2, "{mirrored:?}");
+ }
+
#[test]
fn radial_chip_runs_from_the_centre_out() {
let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4);
diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs
index a6b83f700d..dca209ce04 100644
--- a/crates/ui-egui/src/widgets/chips.rs
+++ b/crates/ui-egui/src/widgets/chips.rs
@@ -82,7 +82,11 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width:
}
// A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle.
let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg());
- let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 });
+ let mut geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 });
+ // A mirrored conic's chip turns the way the gradient does on its object.
+ if gp.gradient.kind == GradientKind::Conic && gp.geom.is_some_and(|g| g.is_mirrored()) {
+ geom.aspect = -1.0;
+ }
(0..n)
.map(|k| {
let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32;
diff --git a/docs/mcp.md b/docs/mcp.md
index 2b838572cb..2c12e496b3 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -452,7 +452,7 @@ so a bad one draws nothing. The object and its styling are one `command.batch`,
it can't read, a swatch the document lacks) fails the call and leaves the document as it was. The reply adds `name`, `opacity` and `effects` (each `effect.apply` result)
when given; `paint` lists the stroke options applied.
A gradient given to several objects fits each one's own bounds; `paint.setFill` / `paint.setStroke` with
-`span: "selection"` place a linear or radial gradient once on the union of the targets' bounds instead, so one
+`span: "selection"` place a linear, radial or conic gradient once on the union of the targets' bounds instead, so one
ramp runs across them.
`paint.setFill` without `item` on a grayscale or 1-bit image sets the color the image is drawn in: each pixel takes
the color as far as the pixel is dark, and white pixels are transparent. `none` removes the color. An image with an
@@ -1048,7 +1048,27 @@ radial shadings:
{"name":"run_command","arguments":{"command":"paint.setGradientGeom","params":{"aspect":60,"focal":[130,140]}}}
```
-Dither (the Gradient panel's box) draws a linear or radial gradient fill with fine noise that hides banding;
+A conic gradient (`kind: "conic"`, also accepted as `angle` or `conical`) sweeps its stops once around the center
+(the start), clockwise on the page from the vector, with a seam where the last stop meets the first. Its annotator
+draws the extent ellipse, aspect dot and center ring of a radial gradient, without a focal point; a `focal` given
+for a conic gradient is an error. A negative `aspect` mirrors the sweep (counterclockwise), as reflecting the object
+does; for the other kinds a negative `aspect` is an error. The Gradient panel's Aspect Ratio field and the aspect dot
+keep a mirrored sweep mirrored. Dither and Method apply as they do to a linear gradient. PDF export writes it as a
+function-based shading (type 1) whose calculator function turns the angle into the ramp, and PDF import reads a
+function-based shading whose color turns around the origin of its space back as a conic gradient, with its stops'
+opacity, also when the origin lies outside the painted area; in a PDF/A file, or with more than 97 stops on its
+curve, it is written as an image. SVG has no conic gradient: one of opaque stops is written as 360 solid wedges
+clipped to the shape, others as an image, and on type characters as a linear gradient. CSS (CSS Properties, a swatch
+library saved as CSS) writes `conic-gradient(from …)`, an elliptical sweep as a circular one. EPS and legacy `.ai`
+files get 360 solid wedges, and the `.ai` editing data a linear gradient along the vector with the same stops.
+Object › Expand turns it into wedges (`steps`) or a mesh of columns around the center. An export that writes it in
+another form gives a warning:
+
+```json
+{"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"kind":"conic","start":[150,150],"end":[200,150],"aspect":-100}}}}
+```
+
+Dither (the Gradient panel's box) draws a linear, radial or conic 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):
@@ -1057,9 +1077,9 @@ SVG and PDF exports write such a fill as an image at the Document Raster Effects
```
Method (the Gradient panel's menu): `classic` (the default) mixes the colours between stops in display RGB,
-`perceptual` mixes a linear or radial gradient's colours in OKLab so the steps look even. SVG exports write a
+`perceptual` mixes a linear, radial or conic gradient's colors in OKLab so the steps look even. SVG exports write a
Perceptual gradient as an image at the Document Raster Effects resolution (a dithered Perceptual fill with its noise).
-PDF exports write it the same way, except for a linear or radial gradient with spot color stops whose other stops
+PDF exports write it the same way, except for a linear, radial or conic gradient with spot color stops whose other stops
are all paper white or written in CMYK or gray, that is not a dithered fill and whose curve does not have too many
stops. 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. PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 5c9c7325f7..8d3b97be13 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1295,6 +1295,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- 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`.
- 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.