diff --git a/ASSETS.md b/ASSETS.md
index 27bbcfc5e7..34d75235c6 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -160,6 +160,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `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-diamond.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-diamond.svg b/assets/icons/dc-grad-diamond.svg
new file mode 100644
index 0000000000..2efeca27e9
--- /dev/null
+++ b/assets/icons/dc-grad-diamond.svg
@@ -0,0 +1 @@
+
diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs
index 18aa1bd189..c5803e3224 100644
--- a/crates/color/src/gradient.rs
+++ b/crates/color/src/gradient.rs
@@ -19,13 +19,18 @@ pub enum GradientKind {
/// 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,
+ /// Diamond gradients: the stops run from the center (`start`) out to a square whose
+ /// half-diagonal is the vector (a diamond when the vector is level), stretched across the
+ /// vector by `aspect` (see [`GradientGeom::diamond_frame`]).
+ Diamond,
}
impl GradientKind {
/// 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 const ALL: [GradientKind; 5] =
+ [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond, GradientKind::Freeform];
+ /// Parse a kind name (`linear`, `radial`, `conic`, `diamond`, `freeform`; any case). `angle`
+ /// and `conical` also mean conic.
pub fn parse(s: &str) -> Option {
if s.eq_ignore_ascii_case("angle") || s.eq_ignore_ascii_case("conical") {
return Some(GradientKind::Conic);
@@ -38,12 +43,13 @@ impl GradientKind {
GradientKind::Radial => "Radial",
GradientKind::Freeform => "Freeform",
GradientKind::Conic => "Conic",
+ GradientKind::Diamond => "Diamond",
}
}
- /// Is it placed by a center (`start`) and an extent ellipse (radial and conic), rather than
- /// by a line?
+ /// Is it placed by a center (`start`) and an extent around it (radial, conic and diamond),
+ /// rather than by a line?
pub fn is_centered(self) -> bool {
- matches!(self, GradientKind::Radial | GradientKind::Conic)
+ matches!(self, GradientKind::Radial | GradientKind::Conic | GradientKind::Diamond)
}
}
@@ -668,13 +674,22 @@ fn one64() -> f64 {
impl GradientGeom {
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
/// radial and conic are centered with radius = half the larger dimension and their vector at
- /// `angle_deg`.
+ /// `angle_deg`, and diamond is centered with its vector at `angle_deg`, its square's edge
+ /// running through the box's farthest corner (at 0 or 90 degrees, through all four).
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 | GradientKind::Conic => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
+ GradientKind::Diamond => {
+ // The square through the box's farthest corner: the largest reach along the vector
+ // plus across it, at one corner.
+ let (w, h) = (b.width() / 2.0, b.height() / 2.0);
+ let level = |x: f64, y: f64| (x * d.x + y * d.y).abs() + (y * d.x - x * d.y).abs();
+ let half = level(w, h).max(level(w, -h));
+ Self { start: c, end: c + d * half, 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;
@@ -694,6 +709,10 @@ impl GradientGeom {
/// 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.
+ /// Diamond gradients keep their vector (a corner of the square) the same way, with the size
+ /// of the mapped extent across it as their aspect: exact for moves, rotations, scales along or
+ /// across the vector and mirrors, and the nearest square stretched across its vector under a
+ /// shear or another scale.
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);
@@ -724,6 +743,14 @@ impl GradientGeom {
};
return;
}
+ if kind == GradientKind::Diamond {
+ 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()).abs();
+ self.aspect = if lu.hypot2() > 1e-18 && aspect.is_finite() { aspect } else { 1.0 };
+ return;
+ }
if kind != GradientKind::Radial {
let far = a * self.end;
let nrm = perp(lin(perp(u)));
@@ -768,6 +795,8 @@ impl GradientGeom {
/// measured from the focal point (each level is the ellipse scaled about the line from the
/// 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.
+ /// For diamond gradients it is the size of the square through `p`, as a fraction of the one
+ /// [`Self::diamond_frame`] gives (1 everywhere for a diamond without a frame).
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
if kind == GradientKind::Conic {
let q = (self.conic_frame().inverse() * p).to_vec2();
@@ -775,6 +804,13 @@ impl GradientGeom {
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 };
}
+ if kind == GradientKind::Diamond {
+ // Without a frame the square has no inside: every point is past its edge.
+ let Some(m) = self.diamond_frame() else { return 1.0 };
+ let q = (m.inverse() * p).to_vec2();
+ let t = q.x.abs() + q.y.abs();
+ return if t.is_nan() { 1.0 } else { t };
+ }
let u = self.end - self.start;
if u.hypot2() < 1e-18 {
return 0.0;
@@ -835,32 +871,52 @@ impl GradientGeom {
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;
+ let r = fan_radius(frame, b);
(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)
+ (fan(frame, r, from, to), (k as f64 + 0.5) / n as f64)
})
.collect()
}
+ /// The frame a diamond gradient is drawn in: its [`Self::unit_frame`], which maps the square
+ /// with corners (±1, 0) and (0, ±1) onto the diamond (its first corner at the end of the
+ /// vector). `None` when the square is flat or not finite (a vector without length, an aspect
+ /// of 0, or a file's out-of-range numbers): such a diamond has no inside, and every point
+ /// takes its last stop.
+ pub fn diamond_frame(&self) -> Option {
+ self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18)
+ }
+
+ /// A diamond gradient as four linear gradients, one for each quadrant of its
+ /// [`Self::diamond_frame`], for formats without diamond gradients: each quadrant's outline (the
+ /// center, then an arc that reaches past every corner of `b`) and the linear gradient that
+ /// equals the diamond there (its stops run across the square's edge in that quadrant), in the
+ /// order they are drawn. Each of the first three runs on past its last edge under the next,
+ /// and the first also back past its first edge under the last, so every edge drawn lies on
+ /// the quadrant before it and no hairline of what lies below shows between them; where they
+ /// overlap, translucent stops would add up, so the stops must be opaque. `None` for a diamond
+ /// without a frame.
+ pub fn diamond_quadrants(&self, b: Rect) -> Option<[(Vec, GradientGeom); 4]> {
+ let frame = self.diamond_frame()?;
+ let r = fan_radius(frame, b);
+ Some(std::array::from_fn(|k| {
+ let first = k as f64 / 4.0;
+ let (from, to) = match k {
+ 0 => (-0.125, 0.375),
+ 3 => (0.75, 1.0),
+ _ => (first, first + 0.375),
+ };
+ // The quadrant's corner of the square, halfway along whose edge t is 1/2.
+ let (sx, sy) = [(1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0)][k];
+ let mut geom = GradientGeom { start: Point::ORIGIN, end: Point::new(sx / 2.0, sy / 2.0), aspect: 1.0, focal: None };
+ geom.transform(frame, GradientKind::Linear);
+ (fan(frame, r, from, to), geom)
+ }))
+ }
+
/// 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 {
@@ -907,6 +963,34 @@ impl GradientGeom {
}
}
+/// Arc points of [`fan`] are at most this much of a turn apart.
+const FAN_ARC: f64 = 1.0 / 32.0;
+
+/// The radius of the unit circle of `frame` at which the chords of [`fan`]'s arcs stay outside
+/// every corner of `b`.
+fn fan_radius(frame: Affine, b: Rect) -> f64 {
+ 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 };
+ reach / (std::f64::consts::PI * FAN_ARC).cos()
+}
+
+/// The sector of `frame`'s unit circle, scaled to radius `r`, from turn `from` to turn `to`
+/// (counted from the frame's x axis toward its y axis) as an outline: the center, then points on
+/// the arc at most [`FAN_ARC`] of a turn apart.
+fn fan(frame: Affine, r: f64, from: f64, to: f64) -> Vec {
+ let at = |turn: f64| {
+ let a = turn * std::f64::consts::TAU;
+ frame * Point::new(r * a.cos(), r * a.sin())
+ };
+ let steps = ((to - from) / FAN_ARC).ceil().max(1.0) as usize;
+ let mut pts = Vec::with_capacity(steps + 2);
+ pts.push(frame * Point::ORIGIN);
+ pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64)));
+ pts
+}
+
/// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side
/// `from` has no extent on, its offset from the centre).
fn rebase_point(p: Point, from: Rect, to: Rect) -> Point {
@@ -1009,7 +1093,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, radial or conic gradient placed on box `bounds` as a `cols` × `rows` grid of
+ /// A linear, radial, conic or diamond 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
@@ -1040,7 +1124,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, radial or conic gradient whose stops are all CMYK, as C, M, Y, K
+ /// [`Self::pixels`] of a linear, radial, conic or diamond 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 c8d0a4bb64..0709d3068b 100644
--- a/crates/color/src/gradient/tests.rs
+++ b/crates/color/src/gradient/tests.rs
@@ -880,3 +880,177 @@ fn conic_pixels_follow_the_sweep() {
let many = GradientPaint { gradient: Gradient::new(GradientKind::Conic, many), ..g };
assert_eq!(many.pixels(b, b, 4, 4, true, &Color::to_rgb).count(), 16);
}
+
+/// A diamond placed at `center` with its vector `dx` long to the right and aspect `aspect`.
+fn diamond_at(center: Point, dx: f64, aspect: f64) -> GradientGeom {
+ GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None }
+}
+
+#[test]
+fn a_diamond_runs_from_its_center_out_to_a_square_with_a_corner_at_the_end_of_its_vector() {
+ let g = diamond_at(Point::new(100.0, 100.0), 50.0, 1.0);
+ let t = |x: f64, y: f64| g.param_at(GradientKind::Diamond, Point::new(x, y));
+ assert!(t(100.0, 100.0).abs() < 1e-12);
+ // The square's corners: at the end of the vector and a quarter turn on from it each way.
+ for (x, y) in [(150.0, 100.0), (100.0, 150.0), (50.0, 100.0), (100.0, 50.0)] {
+ assert!((t(x, y) - 1.0).abs() < 1e-12, "({x}, {y})");
+ }
+ // Halfway along an edge, and halfway out along a diagonal (the middle of the edge's half size).
+ assert!((t(125.0, 125.0) - 1.0).abs() < 1e-12);
+ assert!((t(112.5, 87.5) - 0.5).abs() < 1e-12);
+ // Past the square: more than 1 (the last stop pads on).
+ assert!((t(200.0, 200.0) - 4.0).abs() < 1e-12);
+ // The aspect stretches the square across the vector into a rhombus.
+ let tall = diamond_at(Point::new(100.0, 100.0), 50.0, 2.0);
+ assert!((tall.param_at(GradientKind::Diamond, Point::new(100.0, 200.0)) - 1.0).abs() < 1e-12);
+ assert!((tall.param_at(GradientKind::Diamond, Point::new(150.0, 100.0)) - 1.0).abs() < 1e-12);
+ // Turned: the corner follows the vector.
+ let turned = GradientGeom { end: Point::new(130.0, 140.0), ..g };
+ assert!((turned.param_at(GradientKind::Diamond, Point::new(130.0, 140.0)) - 1.0).abs() < 1e-12);
+ assert!((turned.param_at(GradientKind::Diamond, Point::new(115.0, 120.0)) - 0.5).abs() < 1e-12);
+}
+
+#[test]
+fn diamond_kind_names_and_fit() {
+ for s in ["diamond", "Diamond", "DIAMOND"] {
+ assert_eq!(GradientKind::parse(s), Some(GradientKind::Diamond), "{s}");
+ }
+ assert_eq!(GradientKind::Diamond.label(), "Diamond");
+ assert!(GradientKind::Diamond.is_centered());
+ assert!(GradientKind::ALL.contains(&GradientKind::Diamond));
+ // Level: the square's edges pass through the box's corners, so it covers the box exactly.
+ let b = Rect::new(0.0, 0.0, 200.0, 100.0);
+ let fit = GradientGeom::fit(GradientKind::Diamond, b, 0.0);
+ assert_eq!(fit.start, Point::new(100.0, 50.0));
+ assert!(fit.end.distance(Point::new(250.0, 50.0)) < 1e-9, "{fit:?}");
+ for c in [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)] {
+ assert!((fit.param_at(GradientKind::Diamond, c) - 1.0).abs() < 1e-9, "{c:?}");
+ }
+ // At any angle the square covers the box and runs through its farthest corner, on a square
+ // box too.
+ for b in [b, Rect::new(0.0, 0.0, 100.0, 100.0)] {
+ for angle in [30.0, 45.0, 90.0, -120.0] {
+ let fit = GradientGeom::fit(GradientKind::Diamond, b, angle);
+ let levels = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]
+ .map(|c| fit.param_at(GradientKind::Diamond, c));
+ assert!(levels.iter().all(|&t| t <= 1.0 + 1e-9), "{angle}: {levels:?}");
+ assert!(levels.iter().any(|&t| (t - 1.0).abs() < 1e-9), "{angle}: {levels:?}");
+ }
+ }
+}
+
+#[test]
+fn a_diamond_follows_moves_turns_scales_and_mirrors() {
+ let c = Point::new(10.0, 20.0);
+ let g0 = diamond_at(c, 30.0, 0.5);
+ let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0), c + Vec2::new(-20.0, -4.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),
+ // Along and across the vector.
+ Affine::scale_non_uniform(2.0, 0.5),
+ Affine::scale_non_uniform(-3.0, 0.25),
+ ] {
+ let mut g = g0;
+ g.transform(a, GradientKind::Diamond);
+ assert!(g.start.distance(a * c) < 1e-9, "{a:?}");
+ // A mirror changes nothing a diamond shows: its aspect stays a size.
+ assert!(g.aspect > 0.0, "{a:?}: {g:?}");
+ for p in probes {
+ let (want, got) = (g0.param_at(GradientKind::Diamond, p), g.param_at(GradientKind::Diamond, a * p));
+ assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}");
+ }
+ }
+ // Under a shear the corner 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::Diamond);
+ assert!((g.param_at(GradientKind::Diamond, shear * (c + Vec2::new(30.0, 0.0))) - 1.0).abs() < 1e-9, "{g:?}");
+ // Out-of-range numbers: a finite aspect after any transform.
+ for bad in [diamond_at(c, 30.0, f64::NAN), diamond_at(c, f64::INFINITY, 1.0), diamond_at(c, 0.0, 1.0)] {
+ let mut g = bad;
+ g.transform(Affine::rotate(0.4), GradientKind::Diamond);
+ assert!(g.aspect.is_finite(), "{g:?}");
+ }
+}
+
+/// A diamond without a frame (no vector, a flat or a not-finite extent) has no inside: every
+/// point is past its edge and takes the last stop. Points that aren't numbers do too.
+#[test]
+fn a_diamond_with_no_extent_or_bad_numbers_is_its_last_stop() {
+ let c = Point::new(50.0, 50.0);
+ for g in [
+ diamond_at(c, 0.0, 1.0),
+ diamond_at(c, 30.0, 0.0),
+ diamond_at(c, 30.0, f64::NAN),
+ diamond_at(c, f64::INFINITY, 1.0),
+ GradientGeom { start: Point::new(f64::NAN, 0.0), ..diamond_at(c, 30.0, 1.0) },
+ ] {
+ assert!(g.diamond_frame().is_none(), "{g:?}");
+ assert!(g.diamond_quadrants(Rect::new(0.0, 0.0, 100.0, 100.0)).is_none(), "{g:?}");
+ assert_eq!(g.param_at(GradientKind::Diamond, Point::new(60.0, 40.0)), 1.0, "{g:?}");
+ }
+ let g = diamond_at(c, 30.0, 1.0);
+ assert_eq!(g.param_at(GradientKind::Diamond, Point::new(f64::NAN, 1.0)), 1.0);
+ assert!(g.param_at(GradientKind::Diamond, Point::new(f64::INFINITY, 1.0)) >= 1.0);
+}
+
+/// The four linear gradients a format without diamond gradients gets, drawn in order, equal the
+/// diamond everywhere, and each edge drawn lies on the quadrant drawn before it.
+#[test]
+fn diamond_quadrants_are_linear_gradients_that_equal_the_diamond() {
+ let b = Rect::new(0.0, 0.0, 200.0, 100.0);
+ let probes: Vec = (0..=20).flat_map(|i| (0..=10).map(move |j| Point::new(i as f64 * 10.0 + 0.3, j as f64 * 10.0 + 0.7))).collect();
+ // Squashed and turned, stretched twice as tall as wide, and stretched by a shear.
+ let mut turned = diamond_at(Point::new(60.0, 40.0), 30.0, 0.5);
+ turned.transform(Affine::rotate_about(0.4, Point::new(60.0, 40.0)), GradientKind::Diamond);
+ let mut sheared = diamond_at(Point::new(90.0, 50.0), 25.0, 1.0);
+ sheared.transform(Affine::new([2.0, 0.0, 0.7, 1.0, -60.0, 0.0]), GradientKind::Diamond);
+ for g in [turned, diamond_at(Point::new(100.0, 50.0), 20.0, 2.0), sheared] {
+ let quadrants = g.diamond_quadrants(b).expect("a frame");
+ for p in &probes {
+ // The last quadrant drawn that holds the point paints it.
+ let Some((_, linear)) = quadrants.iter().rev().find(|(poly, _)| inside(poly, *p)) else { panic!("{p:?} is in no quadrant") };
+ let (want, got) = (g.param_at(GradientKind::Diamond, *p), linear.param_at(GradientKind::Linear, *p));
+ assert!((want - got).abs() < 1e-9, "{g:?} at {p:?}: {want} vs {got}");
+ }
+ }
+ let g = turned;
+ let quadrants = g.diamond_quadrants(b).expect("a frame");
+ // Next to each edge of a quadrant drawn later, on either side, the quadrant drawn before it
+ // lies underneath (the first quadrant's edges are covered by the second and the last).
+ let frame = g.diamond_frame().expect("a frame");
+ for (unit, under) in [
+ // The second quadrant's first edge, on the first.
+ (Point::new(0.01, 0.5), 0),
+ (Point::new(-0.01, 0.5), 0),
+ // The third's, on the second.
+ (Point::new(-0.5, 0.01), 1),
+ (Point::new(-0.5, -0.01), 1),
+ // The last's two, on the third and on the first.
+ (Point::new(-0.01, -0.5), 2),
+ (Point::new(0.01, -0.5), 2),
+ (Point::new(0.5, -0.01), 0),
+ (Point::new(0.5, 0.01), 0),
+ ] {
+ assert!(inside(&quadrants[under].0, frame * unit), "{unit:?} lies on quadrant {under}");
+ }
+}
+
+/// A diamond's pixels (what SVG and PDF draw a dithered or Perceptual one as, and what the canvas
+/// draws) follow the squares.
+#[test]
+fn diamond_pixels_follow_the_squares() {
+ let b = Rect::new(0.0, 0.0, 5.0, 5.0);
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, Gradient::default().stops));
+ g.geom = Some(diamond_at(Point::new(2.5, 2.5), 2.5, 1.0));
+ let px: Vec<[u8; 4]> = g.pixels(b, b, 5, 5, false, &Color::to_rgb).collect();
+ // White at the center, black in the corners, and the same on each side of it.
+ assert!(px[2 * 5 + 2][0] > 200 && px[0][0] < 10 && px[24][0] < 10, "{px:?}");
+ assert_eq!(px[2 * 5 + 1], px[2 * 5 + 3]);
+ assert_eq!(px[5 + 2], px[3 * 5 + 2]);
+ assert_eq!(px[2 * 5 + 1], px[5 + 2]);
+}
diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs
index 6be0f2abf5..800785f6f7 100644
--- a/crates/color/src/palette_io.rs
+++ b/crates/color/src/palette_io.rs
@@ -205,6 +205,23 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
GradientKind::Conic => {
format!("conic-gradient(from {}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", "))
}
+ // CSS has no diamond gradient: four layers, each a quarter of the box with
+ // the stops running from the center out to its corner at a diagonal angle,
+ // make the diamond its fit gives a box of any shape: the level at a point
+ // is its distance from the center along both axes over the box's
+ // half-width plus half-height
+ // (https://www.w3.org/TR/css-images-3/#linear-gradients). Turned by an angle
+ // that is not a multiple of 90 degrees, its corners leave the box's axes,
+ // and a circle stands in for it, as for a radial swatch.
+ GradientKind::Diamond if (1e-9..90.0 - 1e-9).contains(&g.angle.rem_euclid(90.0)) => {
+ format!("radial-gradient(circle, {})", stops.join(", "))
+ }
+ GradientKind::Diamond => {
+ let stops = stops.join(", ");
+ [("top left", 315), ("top right", 45), ("bottom right", 135), ("bottom left", 225)]
+ .map(|(corner, deg)| format!("linear-gradient({deg}deg, {stops}) {corner} / 50% 50% no-repeat"))
+ .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 9ee07cb623..eaee50dd59 100644
--- a/crates/color/src/palette_io/tests.rs
+++ b/crates/color/src/palette_io/tests.rs
@@ -186,6 +186,55 @@ fn css_writes_conic_gradient_swatches() {
assert!(text.contains("--back: conic-gradient(from 0deg, #000000 0%, #ffffff 80%);"), "{text}");
}
+/// A diamond gradient swatch (CSS has none) is four layered linear gradients, one a quarter of the
+/// box running from the center out to its corner, for the `background` shorthand.
+#[test]
+fn css_writes_diamond_gradient_swatches_as_four_corner_layers() {
+ let lib = SwatchLibrary {
+ name: "x".into(),
+ swatches: vec![Swatch {
+ name: "Gem".into(),
+ paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }))),
+ global: false,
+ spot: false,
+ }],
+ groups: vec![],
+ };
+ let text = written(&lib, PaletteFormat::Css);
+ // Diagonal angles, not `to `: on a box that isn't square, `to ` would turn
+ // each layer's lines to run through the quarter's other two corners.
+ let layer = |corner: &str, deg: u32| format!("linear-gradient({deg}deg, #ffffff 0%, #000000 100%) {corner} / 50% 50% no-repeat");
+ let want =
+ format!("--gem: {}, {}, {}, {};", layer("top left", 315), layer("top right", 45), layer("bottom right", 135), layer("bottom left", 225));
+ assert!(text.contains(&want), "{text}");
+ // CSS's gradient line for a diagonal angle on a quarter box w × h is (w + h) / √2 long and
+ // starts at the quarter's corner at the box's center: the level at a point x, y from the
+ // center is (|x| + |y|) / (w + h), the level the diamond fitted to the whole box has there.
+ let (w, h) = (100.0f64, 50.0f64);
+ let length = (w + h) / std::f64::consts::SQRT_2;
+ let fit = crate::GradientGeom::fit(GradientKind::Diamond, kurbo::Rect::new(-w, -h, w, h), 0.0);
+ for (x, y) in [(0.0, -50.0), (-100.0, 0.0), (-30.0, -20.0), (-100.0, -50.0)] {
+ // The top-left quarter's layer runs toward (-1, -1).
+ let css = (-x - y) / std::f64::consts::SQRT_2 / length;
+ let model = fit.param_at(GradientKind::Diamond, kurbo::Point::new(x, y));
+ assert!((css - model).abs() < 1e-9, "({x}, {y}): {css} vs {model}");
+ }
+ // Turned a quarter or a half turn, it is the same diamond; turned 30 degrees, a circle stands
+ // in for it.
+ let turned = |angle: f64| {
+ let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() });
+ gp.angle = angle;
+ let lib = SwatchLibrary {
+ name: "x".into(),
+ swatches: vec![Swatch { name: "Gem".into(), paint: Paint::Gradient(Box::new(gp)), global: false, spot: false }],
+ groups: vec![],
+ };
+ written(&lib, PaletteFormat::Css)
+ };
+ assert!(turned(90.0).contains(&want) && turned(-180.0).contains(&want), "{}", turned(90.0));
+ assert!(turned(30.0).contains("--gem: radial-gradient(circle, #ffffff 0%, #000000 100%);"), "{}", turned(30.0));
+}
+
#[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 c057aeb231..b1aa9727ba 100644
--- a/crates/doc/src/appearance.rs
+++ b/crates/doc/src/appearance.rs
@@ -444,7 +444,8 @@ impl Appearance {
}
}
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
- /// at each gradient's center: the start of a radial or conic, the middle of a linear vector).
+ /// at each gradient's center: the start of a radial, conic or diamond, 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() {
diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs
index 935d5e9dc5..1d39e2b4e4 100644
--- a/crates/engine/src/cmd/expand.rs
+++ b/crates/engine/src/cmd/expand.rs
@@ -47,7 +47,7 @@ pub fn specs() -> Vec {
"Expand…",
["Object"],
None,
- "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
+ "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} 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
),
@@ -1283,9 +1283,12 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32
/// 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).
+/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center). A
+/// diamond gradient has its frame ([`vectorcraft_color::GradientGeom::diamond_frame`]; `None`
+/// when it has no extent), the box and the level of the square that reaches its corners.
struct Frame {
conic: Option<(vectorcraft_color::GradientGeom, Rect, f64)>,
+ diamond: Option<(Option, Rect, f64)>,
radial: bool,
start: Point,
focal: Point,
@@ -1323,7 +1326,11 @@ impl Frame {
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 }
+ let diamond = (g.gradient.kind == GradientKind::Diamond).then(|| {
+ let reach = corners.iter().map(|c| geom.param_at(GradientKind::Diamond, *c)).fold(0.0f64, f64::max);
+ (geom.diamond_frame(), bounds, if reach.is_finite() { reach.max(1e-6) * 1.01 } else { 1.0 })
+ });
+ Self { conic, diamond, 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`
@@ -1351,6 +1358,34 @@ impl Frame {
};
return GradientMesh::from_grid(&us, &[0.0, 1.0], &at, &|_, c, _| br[c].1);
}
+ if let Some((frame, b, reach)) = &self.diamond {
+ let Some(frame) = frame else {
+ // No extent: the whole box is the last stop.
+ let last = g.sample_keep_model(1.0);
+ let at = |u: f64, v: f64| Point::new(b.x0 + u * b.width(), b.y0 + v * b.height());
+ return GradientMesh::from_grid(&[0.0, 1.0], &[0.0, 1.0], &at, &|_, _, _| last);
+ };
+ // Rings of squares where the color changes, with columns at the corners: each patch
+ // lies between two rings along one side, where the level runs straight across it.
+ let br = breaks(g, 0.0, *reach, f64::INFINITY);
+ let ts: Vec = br.iter().map(|(t, _)| t / reach).collect();
+ let at = |u: f64, v: f64| *frame * (diamond_perimeter(u).to_vec2() * (v * reach)).to_point();
+ const CORNERS: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0];
+ let mut mesh = GradientMesh::from_grid(&CORNERS, &ts, &at, &|r, _, _| br[r].1);
+ // Every column is at a corner, where `from_grid`'s tangent is the mean of the two
+ // sides: each handle along a ring points a third of the way along its own side, so
+ // the sides stay straight.
+ let side = |v: f64, from: usize, to: Option<&f64>| to.map_or(vectorcraft_geom::Vec2::ZERO, |&u| (at(u, v) - at(CORNERS[from], v)) / 3.0);
+ for (r, &v) in ts.iter().enumerate() {
+ for c in 0..CORNERS.len() {
+ if let Some(point) = mesh.points.get_mut(r * CORNERS.len() + c) {
+ point.handles[0] = side(v, c, CORNERS.get(c + 1));
+ point.handles[1] = side(v, c, c.checked_sub(1).and_then(|i| CORNERS.get(i)));
+ }
+ }
+ }
+ return mesh;
+ }
// 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);
@@ -1367,9 +1402,9 @@ 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), 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.
+ /// center from each band's start to the seam, for a diamond one, nested squares (largest
+ /// first). Each paints over the ones below except its band, so no seam shows between bands.
+ /// With translucent stops each shape is just its band, so nothing doubles up.
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
if let Some((geom, b, _)) = &self.conic {
@@ -1379,6 +1414,29 @@ impl Frame {
.map(|(pts, t)| (PathData::single(SubPath::polyline(&pts, true)), g.sample_keep_model(t as f32)))
.collect();
}
+ if let Some((frame, b, reach)) = &self.diamond {
+ let Some(frame) = frame else {
+ return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))];
+ };
+ // Nested squares about the center, largest first, as a radial gradient's ellipses.
+ let step = reach / n as f64;
+ let square = |t: f64| -> SubPath {
+ let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect();
+ SubPath::polyline(&pts, true)
+ };
+ return (0..n)
+ .rev()
+ .map(|k| {
+ let mut shape = PathData::single(square((k + 1) as f64 * step));
+ if !opaque && k > 0 {
+ let mut hole = square(k as f64 * step);
+ hole.reverse();
+ shape.subpaths.push(hole);
+ }
+ (shape, g.sample_keep_model(((k as f64 + 0.5) * step) 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);
if self.radial {
@@ -1410,3 +1468,14 @@ impl Frame {
.collect()
}
}
+
+/// The point a fraction `u` (0..1) of the way around the square with corners (1, 0), (0, 1),
+/// (-1, 0) and (0, -1), from the first: a diamond gradient's last stop in its frame.
+fn diamond_perimeter(u: f64) -> Point {
+ const CORNERS: [(f64, f64); 5] = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0), (1.0, 0.0)];
+ let s = if u.is_finite() { u.clamp(0.0, 1.0) * 4.0 } else { 0.0 };
+ let i = (s.floor() as usize).min(3);
+ let f = s - i as f64;
+ let ((x0, y0), (x1, y1)) = (CORNERS.get(i).copied().unwrap_or((1.0, 0.0)), CORNERS.get(i + 1).copied().unwrap_or((1.0, 0.0)));
+ Point::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f)
+}
diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs
index 9676c3b825..9003e325a9 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|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",
+ "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
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 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)",
+ "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, 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 or diamond 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, conic, freeform)"))
+ GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform)"))
}
/// The `method` param of `p`, if given: how the colours between the stops mix.
@@ -275,7 +275,7 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed {
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 {
+ if matches!(kind, GradientKind::Conic | GradientKind::Diamond) {
no_focal(&geom)?;
}
if let Some((start, end)) = geom.vector {
@@ -315,7 +315,7 @@ fn aspect_for(kind: GradientKind, a: f64) -> Parsed {
Ok(a)
}
-/// An error when `e` gives a focal point: a conic gradient has none (`null` is allowed).
+/// An error when `e` gives a focal point: a conic or diamond gradient has none (`null` is allowed).
fn no_focal(e: &GeomEdit) -> Parsed<()> {
match e.focal {
Some(Some(_)) => Err("`focal` applies to radial gradients".into()),
@@ -645,7 +645,7 @@ impl GeomEdit {
/// 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. 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).
+/// a conic's or diamond's focal point).
fn vector_paint(
cur: &Paint,
e: &GeomEdit,
@@ -656,7 +656,7 @@ fn vector_paint(
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 {
+ if matches!(kind, GradientKind::Conic | GradientKind::Diamond) {
no_focal(e)?;
}
let Some(from_doc) = invert(to_doc) else { return Ok(None) };
diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs
index 93c4cbf2c7..b199f9a356 100644
--- a/crates/engine/src/cmd/paint.rs
+++ b/crates/engine/src/cmd/paint.rs
@@ -21,7 +21,7 @@ pub fn specs() -> Vec {
"Fill",
[],
None,
- "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|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)}",
+ "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
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, radial or conic gradient the targets
+/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial, conic or diamond 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, radial or conic gradient, not a freeform one".into());
+ return Err("span: \"selection\" places a linear, radial, conic or diamond 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 f3bb512008..c809d58090 100644
--- a/crates/engine/src/cmd/swatch.rs
+++ b/crates/engine/src/cmd/swatch.rs
@@ -404,6 +404,7 @@ fn gradient_key(g: &Gradient) -> Option> {
GradientKind::Radial => 1,
GradientKind::Freeform => 2,
GradientKind::Conic => 3,
+ GradientKind::Diamond => 4,
};
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 a3f7a2ae6e..73e0172da5 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", "conic", "freeform"] {
+ for kind in ["linear", "radial", "conic", "diamond", "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);
@@ -586,7 +586,7 @@ fn a_conic_gradient_is_set_edited_transformed_and_saved() {
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}");
+ assert!(e.contains("(linear, radial, conic, diamond, 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();
@@ -747,3 +747,137 @@ fn making_a_gradient_conic_is_one_undo_step() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(fill_gradient(&s, id), before);
}
+
+/// A diamond gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params
+/// agents and swatches read, and the native format.
+#[test]
+fn a_diamond_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": "diamond"}})).unwrap();
+ let g = fill_gradient(&s, id);
+ assert_eq!(g.gradient.kind, GradientKind::Diamond);
+ // Unplaced, it runs from the box's center out to a square whose edges pass through the box's
+ // corners.
+ let geom = g.resolve(node(&s, id).geometric_bounds().unwrap());
+ assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(350.0, 150.0)));
+ for (p, t) in [
+ (Point::new(300.0, 200.0), 1.0),
+ (Point::new(100.0, 100.0), 1.0),
+ (Point::new(250.0, 150.0), 1.0 / 3.0),
+ (Point::new(200.0, 175.0), 1.0 / 6.0),
+ ] {
+ assert!((geom.param_at(GradientKind::Diamond, p) - t).abs() < 1e-9, "{p:?}");
+ }
+ s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
+ s.execute("paint.editGradient", &json!({"kind": "DIAMOND"})).unwrap();
+ assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Diamond);
+ let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string();
+ assert!(e.contains("(linear, radial, conic, diamond, 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 keeps each point's color.
+ let p = Point::new(230.0, 170.0);
+ let before = g.param_at(GradientKind::Diamond, p);
+ s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
+ let m = fill_geom(&s, id);
+ assert!(m.aspect > 0.0, "{m:?}");
+ assert!((m.param_at(GradientKind::Diamond, Point::new(170.0, 170.0)) - before).abs() < 1e-9);
+ // A rotation carries it along.
+ let before = m.param_at(GradientKind::Diamond, 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::Diamond, 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("diamond"), 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::Diamond, want.geom));
+}
+
+/// A diamond refuses a negative aspect and a focal point, from every command that gives one, and
+/// the gradient stays as it was; a null focal point is allowed.
+#[test]
+fn a_diamond_refuses_a_negative_aspect_and_a_focal_point() {
+ let mut s = session();
+ let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "aspect": 50}})).unwrap();
+ let before = fill_gradient(&s, id);
+ for p in [
+ json!({"gradient": {"kind": "diamond", "aspect": -50}}),
+ json!({"gradient": {"kind": "diamond", "start": [0, 0], "end": [10, 0], "aspect": -50}}),
+ ] {
+ assert!(s.execute("paint.setFill", &p).unwrap_err().to_string().contains("aspect"), "{p}");
+ }
+ 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": "radial", "aspect": -50})).is_err());
+ let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": [210, 150]}}));
+ assert!(e.unwrap_err().to_string().contains("focal"));
+ assert!(s.execute("paint.setGradientGeom", &json!({"focal": [210, 150]})).unwrap_err().to_string().contains("focal"));
+ assert_eq!(fill_gradient(&s, id), before);
+ s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": null}})).unwrap();
+ assert_eq!(fill_gradient(&s, id).geom.unwrap().focal, None);
+}
+
+/// A free distort that is not affine moves a diamond by the warp near its center, as it moves a
+/// radial gradient.
+#[test]
+fn a_warp_moves_a_diamond_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 (diamond, radial) = (warped("diamond"), warped("radial"));
+ assert!(diamond.start.distance(radial.start) < 1e-9, "{diamond:?} vs {radial:?}");
+}
+
+/// Saved as `.ai`, as PDF with its editing data and as SVG with its editing data, then opened, a
+/// turned, squashed diamond comes back as it was.
+#[test]
+fn a_diamond_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": "diamond", "start": [180, 140], "end": [240, 110], "aspect": 60}})).unwrap();
+ let want = fill_gradient(&s, id);
+ for format in ["ai", "pdf", "svg"] {
+ let r = s.execute("file.saveCopy", &json!({"format": format, "preserveEditing": true})).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());
+ }
+ });
+ let got = found.unwrap_or_else(|| panic!("{format}: the gradient is lost"));
+ assert_eq!((got.gradient.kind, got.geom), (GradientKind::Diamond, want.geom), "{format}");
+ }
+}
+
+/// Turning a gradient diamond is one undo step, and undoing it gives the gradient back as it was.
+#[test]
+fn making_a_gradient_diamond_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": "diamond", "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 f638355c52..84e5559851 100644
--- a/crates/engine/src/tests/objexpand.rs
+++ b/crates/engine/src/tests/objexpand.rs
@@ -393,6 +393,77 @@ fn a_mirrored_or_flat_conic_expands_as_the_canvas_draws_it() {
}
}
+/// Three opaque stops running from the center out to the square.
+fn diamond_stops() -> Value {
+ json!({"kind": "diamond", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]})
+}
+
+#[test]
+fn a_diamond_gradient_becomes_nested_squares_or_a_mesh_that_paint_it() {
+ // Objects: nested squares about the center, largest first, together painting what the
+ // canvas painted.
+ let mut s = session();
+ gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false);
+ let before = render(&s);
+ run(&mut s, "object.expand", json!({"steps": 120}));
+ let g = selected(&s);
+ let (_, squares) = clip_parts(&g);
+ assert_eq!(squares.len(), 120);
+ let corners = |n: &Node| n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect::>();
+ let (outer, inner) = (corners(squares[0]), corners(squares[119]));
+ assert_eq!((outer.len(), inner.len()), (4, 4));
+ let reach = |pts: &[vectorcraft_geom::Point]| pts.iter().map(|p| p.distance(vectorcraft_geom::Point::new(100.0, 100.0))).fold(0.0, f64::max);
+ assert!(reach(&outer) > reach(&inner) * 100.0, "largest first: {outer:?} then {inner:?}");
+ 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 square is a ring with the next one cut out, so none doubles up.
+ let mut s = session();
+ let gr = json!({"kind": "diamond", "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": 60}));
+ let g = selected(&s);
+ let (_, squares) = clip_parts(&g);
+ assert!(squares[..59].iter().all(|n| n.path_data().unwrap().subpaths.len() == 2), "rings");
+ assert!(mean_diff(&before, &render(&s)) < 4.0, "{}", mean_diff(&before, &render(&s)));
+ // Mesh: rings of patches about the center.
+ let mut s = session();
+ gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_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 turned, squashed diamond, and one whose square is flat (as a file can hold it), expand as
+/// the canvas draws them.
+#[test]
+fn a_turned_or_flat_diamond_expands_as_the_canvas_draws_it() {
+ for aspect in [0.6, 0.0] {
+ for how in [json!({"steps": 120}), json!({"gradient": "mesh"})] {
+ let mut s = session();
+ let id = gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_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/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index a99646c9c2..f2ba516b46 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -7575,3 +7575,45 @@ fn conic_gradients_are_written_as_linear_gradients_with_their_stops() {
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);
}
+
+/// The data has no diamond gradient: a diamond fill is the radial gradient through its square's
+/// corners (centered on it, its vector and the aspect across it), with the same stops, and so is
+/// a diamond gradient swatch, with a warning.
+#[test]
+fn diamond_gradients_are_written_as_radial_gradients_through_their_corners() {
+ 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::Diamond, stops.clone()));
+ let (start, end) = (vectorcraft_geom::Point::new(100.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0));
+ // A focal point (a plug-in's or a file's) draws nothing on a diamond and isn't written.
+ let focal = Some(vectorcraft_geom::Point::new(110.0, 55.0));
+ g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 0.5, focal });
+ let mut doc = Document::new(200.0, 100.0);
+ let id = doc.alloc_id();
+ let l = doc.layers[0].id;
+ let fill = Paint::Gradient(Box::new(g.clone()));
+ doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))).unwrap();
+ let mut swatches: Vec = doc.swatches.iter().cloned().collect();
+ swatches.push(Swatch { name: "Gem".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("diamond gradients are written as radial 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::Radial);
+ 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.aspect - 0.5).abs() < 0.01, "{geom:?}");
+ assert!(geom.focal.is_none_or(|f| f.distance(start) < 0.01), "{geom:?}");
+ let Some(Paint::Gradient(swatch)) = back.swatch("Gem").map(|s| &s.paint) else { panic!("the swatch is lost") };
+ assert_eq!(swatch.gradient.kind, GradientKind::Radial);
+}
diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs
index 892a0d1b65..0dbe24c937 100644
--- a/crates/eps/src/ai_editing/writer/paints.rs
+++ b/crates/eps/src/ai_editing/writer/paints.rs
@@ -45,12 +45,22 @@ pub(super) struct PatternOut {
used: bool,
}
-/// 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`])?
+pub(in crate::ai_editing) const DIAMOND: &str =
+ "diamond gradients are written as radial gradients through their corners, with the same stops, in the editing data for other apps";
+
+/// Can `g` be written as a gradient (a linear, radial, conic or diamond one with stops, no more
+/// than the `.ai` reader reads; a conic is written linear, [`CONIC`], and a diamond radial,
+/// [`DIAMOND`])?
fn writable_gradient(g: &Gradient) -> bool {
g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS
}
+/// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse
+/// through its square's corners, [`DIAMOND`])?
+fn written_radial(kind: GradientKind) -> bool {
+ matches!(kind, GradientKind::Radial | GradientKind::Diamond)
+}
+
/// The first name of `Gradient`, `Gradient 2`, `Gradient 3`, … numbered `next` or higher for
/// which `taken` returns false (`Gradient` is number 1); `next` is set past it.
pub(in crate::ai_editing) fn free_gradient_name(next: &mut u64, mut taken: impl FnMut(&str) -> bool) -> String {
@@ -128,7 +138,7 @@ impl Writer<'_> {
let Paint::Gradient(g) = p else { return p.clone() };
let geom = g.resolve(bounds);
let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite());
- let radial = g.gradient.kind == GradientKind::Radial;
+ let radial = written_radial(g.gradient.kind);
if g.gradient.kind == GradientKind::Freeform {
self.warn(GRADIENTS);
return Paint::solid(average(&g.gradient));
@@ -244,7 +254,7 @@ impl Writer<'_> {
self.instance_head(g, clip, path);
let m = self.grad_space;
let u = geom.end - geom.start;
- let radial = g.gradient.kind == GradientKind::Radial;
+ let radial = written_radial(g.gradient.kind);
// The unit vector along the gradient, and across it as long.
let frame = |from: f64, to: f64| {
let p = geom.start + u * from;
@@ -275,10 +285,15 @@ impl Writer<'_> {
/// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as
/// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`.
pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) {
- let Some(geom) = g.geom else { return };
+ let Some(mut geom) = g.geom else { return };
+ if g.gradient.kind != GradientKind::Radial {
+ // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can
+ // carry one that draws nothing): a diamond's circle stays centered.
+ geom.focal = None;
+ }
let name = self.define_gradient(&g.gradient, g.swatch.as_deref());
let m = self.grad_space;
- let radial = g.gradient.kind == GradientKind::Radial;
+ let radial = written_radial(g.gradient.kind);
let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas);
if radial {
let c = m * geom.start;
@@ -305,8 +320,11 @@ impl Writer<'_> {
if d.gradient.kind == GradientKind::Conic {
self.warn(CONIC);
}
+ if d.gradient.kind == GradientKind::Diamond {
+ self.warn(DIAMOND);
+ }
let name = ps::bytes(d.name.as_bytes());
- let radial = d.gradient.kind == GradientKind::Radial;
+ let radial = written_radial(d.gradient.kind);
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {} {} Bd\n[", u8::from(radial), d.gradient.stops.len());
let mut stops = sorted_stops(&d.gradient);
if !radial {
@@ -508,7 +526,7 @@ impl Writer<'_> {
let swatch = Some(sw.name.as_str());
let name = self.define_gradient(&g.gradient, swatch);
s.push_str("Bb\n");
- if g.gradient.kind == GradientKind::Radial {
+ if written_radial(g.gradient.kind) {
s.push_str("0 0 0 0 Bh\n");
}
let _ = writeln!(s, "2 {} 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB", ps::bytes(name.as_bytes()));
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index 68c7610308..e256e305c2 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -108,6 +108,7 @@ pub const NAMES: &str = "object names are left out (legacy .ai files name only l
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)";
+const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)";
/// Wedges a conic gradient is written as: one a degree.
const CONIC_WEDGES: usize = 360;
const STROKES: &str =
diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs
index 60eb9b0c1c..5528ad7529 100644
--- a/crates/eps/src/legacy_ai/encoding.rs
+++ b/crates/eps/src/legacy_ai/encoding.rs
@@ -201,7 +201,9 @@ 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) => !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic) && !g.gradient.stops.is_empty(),
+ Paint::Gradient(g) => {
+ !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic | GradientKind::Diamond) && !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 8917c9aa73..43b28c934a 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -110,6 +110,35 @@ fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() {
}
}
+/// The format has no diamond gradient: a diamond fill, and a diamond stroke (written as its filled
+/// outline), are four path objects clipped to the shape, each a linear gradient over a quadrant
+/// of the diamond's frame, with a warning; the definition is not named after the swatch.
+#[test]
+fn diamond_gradients_are_written_as_four_linear_gradients_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::Diamond, stops));
+ g.geom = Some(vectorcraft_color::GradientGeom {
+ start: vectorcraft_geom::Point::new(100.0, 50.0),
+ end: vectorcraft_geom::Point::new(140.0, 30.0),
+ aspect: 0.7,
+ focal: None,
+ });
+ g.swatch = Some("Gem".into());
+ let diamond = Paint::Gradient(Box::new(g));
+ for (fill, stroke) in [(diamond.clone(), Paint::None), (Paint::None, diamond)] {
+ 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 == DIAMOND), "{:?}", out.warnings);
+ let text = String::from_utf8_lossy(&out.bytes);
+ assert_eq!(text.matches("%AI5_BeginGradient: (Gradient)").count(), 1, "one linear definition: {text}");
+ assert!(!text.contains("(Gem)"), "not named after the swatch");
+ assert_eq!(text.matches(" Bg\n").count(), 4, "a gradient a quadrant");
+ }
+}
+
/// `doc` written with options `o`. Every file the tests write is also run as a PostScript program
/// ([`prints_as`]) and must draw what `doc` draws.
fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index 068f534868..8f8fc4ccf2 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -9,7 +9,7 @@ use super::{
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
};
-use super::{CONIC, CONIC_WEDGES};
+use super::{CONIC, CONIC_WEDGES, DIAMOND};
pub(super) struct Writer<'a> {
doc: &'a Document,
@@ -429,6 +429,11 @@ impl<'a> Writer<'a> {
{
return self.conic_object(path, rule, f, g);
}
+ if let Paint::Gradient(g) = &f.paint
+ && g.gradient.kind == GradientKind::Diamond
+ {
+ return self.diamond_object(path, rule, f, g);
+ }
let Paint::Pattern { pattern, xf } = &f.paint else {
// A freeform gradient: its average colour.
self.warn(FREEFORM);
@@ -497,6 +502,41 @@ impl<'a> Writer<'a> {
self.line("Q");
}
+ /// A diamond gradient fill (the format has no diamond gradient) as four linear gradients
+ /// clipped to `path` (filled by `rule`), one for each quadrant of its frame and equal to the
+ /// diamond there, each running on under the next
+ /// ([`vectorcraft_color::GradientGeom::diamond_quadrants`]). A diamond without an extent is its
+ /// last stop.
+ fn diamond_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer, g: &GradientPaint) {
+ let area = path.to_bezpath().bounding_box();
+ let Some(quadrants) = g.resolve(area).diamond_quadrants(area) else {
+ let color = g.gradient.stops.last().map_or(Color::gray(0.5), |s| s.color);
+ let flat = FillLayer { paint: Paint::solid(color), ..f.clone() };
+ return self.path_object(path, rule, Some(&flat), None);
+ };
+ self.warn(DIAMOND);
+ 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");
+ }
+ let gradient = Gradient { kind: GradientKind::Linear, ..g.gradient.clone() };
+ for (pts, geom) in quadrants {
+ // Not named after the swatch: the definition is a linear gradient, not the swatch's.
+ let linear = GradientPaint { gradient: gradient.clone(), geom: Some(geom), swatch: None, ..g.clone() };
+ let quadrant = FillLayer { paint: Paint::Gradient(Box::new(linear)), ..f.clone() };
+ self.path_object(&PathData::single(SubPath::polyline(&pts, true)), FillRule::NonZero, Some(&quadrant), 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 3b2a91f0ff..96205aa7d3 100644
--- a/crates/eps/src/scene.rs
+++ b/crates/eps/src/scene.rs
@@ -43,6 +43,7 @@ const SPOT_GRADIENTS_L2: &str =
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)";
+const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond 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).
@@ -934,9 +935,9 @@ impl<'a> Scene<'a> {
(space, out)
}
- /// 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).
+ /// Paint all of `area` (inside the current clip) with linear, radial, conic or diamond gradient
+ /// `g`, placed on `bounds`: a smooth shading at Level 3, else stepped fills (a conic: wedges at
+ /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]).
fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
let geom = g.resolve(bounds);
let (space, stops) = self.gradient_stops(&g.gradient);
@@ -951,6 +952,7 @@ impl<'a> Scene<'a> {
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::Diamond => self.diamond_quadrants(&geom, area, space, &stops, smooth),
GradientKind::Radial => {
let r = geom.length().max(1e-6);
let squash = geom.radial_squash();
@@ -999,6 +1001,43 @@ impl<'a> Scene<'a> {
}
}
+ /// A diamond gradient over `area` as four linear gradients, each clipped to a quadrant of its
+ /// frame and equal to the diamond there (PostScript has no diamond shading;
+ /// [`vectorcraft_color::GradientGeom::diamond_quadrants`]): smooth axial shadings, else
+ /// stepped fills. Each quadrant but the last runs on under the next (stops are opaque here),
+ /// so no hairline shows between them. A diamond without an extent is its last stop.
+ fn diamond_quadrants(
+ &mut self,
+ geom: &vectorcraft_color::GradientGeom,
+ area: Rect,
+ space: &GradientSpace,
+ stops: &[(f64, Vec)],
+ smooth: bool,
+ ) {
+ let Some(quadrants) = geom.diamond_quadrants(area) else {
+ if let Some((_, c)) = stops.last() {
+ self.set_comps(space, c);
+ self.rect_fill(area);
+ }
+ return;
+ };
+ self.warn(DIAMOND_QUADRANTS);
+ for (pts, q) in quadrants {
+ self.line("q");
+ 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 clip newpath");
+ if smooth {
+ self.shading(2, space, &[q.start.x, q.start.y, q.end.x, q.end.y], stops);
+ } else {
+ self.linear_steps(q.start, q.end, area, space, stops);
+ }
+ self.line("Q");
+ }
+ }
+
/// 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 96cf75c02a..db9ae2837c 100644
--- a/crates/eps/src/tests.rs
+++ b/crates/eps/src/tests.rs
@@ -217,6 +217,33 @@ fn conic_gradients_are_solid_wedges_at_both_levels() {
}
}
+/// PostScript has no diamond shading: a diamond is four axial shadings at Level 3 (stepped fills at
+/// Level 2), each clipped to a quadrant of its frame, and read back from the PostScript it looks
+/// as the canvas paints it, with no hairline between the quadrants.
+#[test]
+fn diamond_gradients_are_four_linear_gradients_clipped_to_quadrants() {
+ 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::Diamond, stops));
+ g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(100.0, 70.0), end: Point::new(140.0, 40.0), aspect: 0.6, 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, shadings) in [(opts(&d), 4), (EpsOptions { level: Level::Two, ..opts(&d) }, 0)] {
+ let out = export(&d, &o, None, None).unwrap();
+ assert!(out.warnings.iter().any(|w| w.contains("diamond gradients are written as four linear gradients")), "{:?}", out.warnings);
+ let page = page_of(&d, &o);
+ assert_eq!(page.matches("shfill").count(), shadings);
+ assert_eq!(page.matches("closepath clip newpath").count(), 4, "a clip a quadrant");
+ }
+ 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),
+ 6.0,
+ 0.01,
+ );
+}
+
#[test]
fn strokes_keep_their_caps_joins_and_dashes() {
let d = doc_with(|d| {
diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs
index 760021b25a..90b7ae5248 100644
--- a/crates/pdf/src/export/nodes.rs
+++ b/crates/pdf/src/export/nodes.rs
@@ -433,14 +433,17 @@ 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`]), or a conic gradient that can't be a sweep shading (in
- /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops)?
+ /// inks ([`Self::perceptual_spot`]), a conic gradient that can't be a sweep shading (in
+ /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), or a diamond gradient
+ /// (four linear gradients, [`Self::diamond`])?
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) => {
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())
+ (g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill))
+ || (g.gradient.kind == GradientKind::Conic && sweep_unwritable())
+ || g.gradient.kind == GradientKind::Diamond
}
_ => false,
}
@@ -448,9 +451,10 @@ impl Exporter<'_> {
/// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity,
/// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a
- /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image
- /// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the
- /// canvas), clipped to it. False (nothing drawn) for other paints.
+ /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), a diamond
+ /// gradient's four linear gradients, or an image of a gradient drawn as pixels, placed on
+ /// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn)
+ /// for other paints.
#[allow(clippy::too_many_arguments)]
pub(super) fn area(
&mut self,
@@ -498,7 +502,12 @@ impl Exporter<'_> {
}
}
}
- Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill),
+ Paint::Gradient(g) => {
+ let pixels = g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill);
+ if pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area) {
+ self.field_image(s, g, bounds, area, fill);
+ }
+ }
_ => {}
}
}
diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs
index 15f3f46637..38ab52f182 100644
--- a/crates/pdf/src/export/paint.rs
+++ b/crates/pdf/src/export/paint.rs
@@ -338,10 +338,14 @@ impl Exporter<'_> {
.into(),
)
}
- // Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient
- // slices) the average colour stands in.
- GradientKind::Freeform => {
- self.warn("freeform gradients are exported as their average colour");
+ // Painted areas get an image of the field, or a diamond's four linear gradients
+ // ([`Self::area`]); elsewhere (gradient slices) the average color stands in.
+ GradientKind::Freeform | GradientKind::Diamond => {
+ self.warn(if g.gradient.kind == GradientKind::Diamond {
+ "diamond gradients in gradient slices are exported as their average color"
+ } else {
+ "freeform gradients are exported as their average colour"
+ });
let n = g.gradient.stops.len().max(1) as f32;
let mut acc = [0.0f32; 3];
for s in &g.gradient.stops {
@@ -476,10 +480,43 @@ impl Exporter<'_> {
g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)))
}
+ /// Draw diamond gradient `g`, placed on `bounds`, over `area` (inside the current clip) as four
+ /// linear gradients, one for each quadrant of its frame, each equal to the diamond there (PDF
+ /// has no diamond shading; [`vectorcraft_color::GradientGeom::diamond_quadrants`]). The
+ /// quadrants overlap so no hairline shows between them, which needs opaque stops. False
+ /// (nothing drawn) for a diamond with translucent stops or without a frame, which
+ /// [`Self::field_image`] draws instead.
+ pub(super) fn diamond(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool {
+ if g.gradient.stops.iter().any(|st| st.opacity < 1.0) {
+ return false;
+ }
+ let Some(quadrants) = g.resolve(bounds).diamond_quadrants(area.abs()) else { return false };
+ self.warn("diamond gradients are exported as four linear gradients");
+ let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..g.gradient.clone() };
+ for (outline, geom) in quadrants {
+ let linear = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: gradient.clone(), geom: Some(geom), ..g.clone() }));
+ let quadrant = kurbo::BezPath::from_vec(
+ outline
+ .iter()
+ .enumerate()
+ .map(|(i, &p)| if i == 0 { kurbo::PathEl::MoveTo(p) } else { kurbo::PathEl::LineTo(p) })
+ .chain([kurbo::PathEl::ClosePath])
+ .collect(),
+ );
+ if let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) {
+ s.set_stroke(None);
+ s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
+ s.draw_path(&path);
+ s.set_fill(None);
+ }
+ }
+ true
+ }
+
/// 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, radial or conic gradient (at most [`MAX_GRADIENT_PX`] a side),
+ /// [`MAX_FIELD_PX`] a side), or a linear, radial, conic or diamond 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.
@@ -505,6 +542,8 @@ impl Exporter<'_> {
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
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 g.gradient.kind == GradientKind::Diamond {
+ self.warn("diamond gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images");
} else if !freeform {
self.warn("linear and radial gradients drawn as pixels are exported as images");
}
diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs
index ef91bdad42..2cafbf010a 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 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
+//! function-based one whose color changes along one axis a linear gradient, one whose color
+//! turns around the origin of its space a conic gradient, and one whose color is the same along
+//! each square about that origin a diamond gradient (else its average color; all of them
//! 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
@@ -180,7 +181,9 @@ pub(crate) fn function_paint(
if !matches!(sp.shading.shading_type.as_ref(), ShadingType::FunctionBased { .. }) {
return None;
}
- let gradient = function_gradient(sp, colors, inks, budget).or_else(|| function_sweep(sp, colors, inks, budget));
+ let gradient = function_gradient(sp, colors, inks, budget)
+ .or_else(|| function_sweep(sp, colors, inks, budget))
+ .or_else(|| function_diamond(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),
@@ -362,6 +365,59 @@ fn function_sweep(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[S
Some(g)
}
+/// A function-based shading (type 1) whose domain holds the origin of its space and whose color
+/// is the same along each square about it with corners on the axes (a function of `abs(x) +
+/// abs(y)`, as a calculator function gives it, ISO 32000-1 7.10.5) as a diamond gradient, its
+/// vector along the space's x axis to the corner of the largest such square the domain reaches
+/// (through the domain's farthest corner): stops sampled from the origin out to that corner, as
+/// [`function_stops`] samples them. `None` when the domain does not hold the origin, the color
+/// differs between grid points on one square or doesn't change from square to square, the stops
+/// would be more than [`MAX_FUNCTION_STOPS`], or `budget` runs out (leaving
+/// [`AVERAGE_EVALUATIONS`] for [`function_average`]).
+fn function_diamond(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>, budget: &FunctionBudget) -> Option {
+ 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;
+ if !(x0 < 0.0 && x1 > 0.0 && y0 < 0.0 && y1 > 0.0) {
+ return None;
+ }
+ let rgb = |x: f32, y: f32| function_rgb(sp, function, x, y, budget, AVERAGE_EVALUATIONS);
+ let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
+ // The farthest corner of the domain, and a point on each square up to it inside the domain:
+ // along the x axis toward that corner, then up its side.
+ let (fx, fy) = (if x1 >= -x0 { x1 } else { x0 }, if y1 >= -y0 { y1 } else { y0 });
+ let reach = fx.abs() + fy.abs();
+ let on_square = |level: f32| {
+ let x = level.clamp(0.0, fx.abs());
+ (x.copysign(fx), (level - x).clamp(0.0, fy.abs()).copysign(fy))
+ };
+ // Each grid point has the color of that point on its square, and the color changes from
+ // square to square.
+ let mut turns = false;
+ let origin = rgb(0.0, 0.0)?;
+ for j in 0..=FUNCTION_GRID {
+ for i in 0..=FUNCTION_GRID {
+ let (x, y) = (lerp(x0, x1, grid_at(i)), lerp(y0, y1, grid_at(j)));
+ let (sx, sy) = on_square(x.abs() + y.abs());
+ let on_path = rgb(sx, sy)?;
+ if !same_rgb(rgb(x, y)?, on_path) {
+ return None;
+ }
+ turns |= !same_rgb(on_path, origin);
+ }
+ }
+ if !turns {
+ return None;
+ }
+ let out = function_stops(sp, function, colors, inks, budget, &|s: f32| on_square(s * reach))?;
+ let mut geom = GradientGeom { start: Point::ZERO, end: Point::new(f64::from(reach), 0.0), aspect: 1.0, focal: None };
+ geom.transform(sp.matrix * *matrix, GradientKind::Diamond);
+ let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, 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;
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index 174172f408..d8238d8038 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -17,6 +17,7 @@ mod colorize;
mod compression;
mod conic;
mod dashalign;
+mod diamond;
mod editing;
mod encrypt;
mod file_info;
diff --git a/crates/pdf/src/tests/diamond.rs b/crates/pdf/src/tests/diamond.rs
new file mode 100644
index 0000000000..8f0e4d8946
--- /dev/null
+++ b/crates/pdf/src/tests/diamond.rs
@@ -0,0 +1,118 @@
+//! Diamond gradients (PDF has no diamond shading): four axial shadings, one for each quadrant of
+//! the diamond's frame and equal to it there, drawn as the canvas draws the diamond; an image
+//! where the gradient is drawn as pixels or has no extent.
+
+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 from the center out.
+fn rgb_diamond() -> 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::Diamond, stops)
+}
+
+/// Turned and stretched across its vector, off the square's center.
+fn placed() -> GradientGeom {
+ GradientGeom { start: Point::new(90.0, 110.0), end: Point::new(150.0, 70.0), aspect: 0.6, focal: None }
+}
+
+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)
+}
+
+/// Written as four axial shadings and read back, a diamond draws as the canvas draws it, with no
+/// hairline between the quadrants: level and placed, and in PDF/A, which allows axial shadings.
+#[test]
+fn a_diamond_is_written_as_four_axial_shadings() {
+ let level = GradientGeom::fit(GradientKind::Diamond, Rect::new(0.0, 0.0, 200.0, 200.0), 0.0);
+ let mut pdfa = PdfOptions::uncompressed();
+ (pdfa.settings.standard, pdfa.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version());
+ for (case, d, o) in [
+ ("level", doc(rgb_diamond(), level), PdfOptions::uncompressed()),
+ ("placed", doc(rgb_diamond(), placed()), PdfOptions::uncompressed()),
+ ("PDF/A", doc(rgb_diamond(), placed()), pdfa),
+ ] {
+ let (text, warnings) = uncompressed(&d, &o);
+ assert_eq!(text.matches("/ShadingType 2").count(), 4, "{case}");
+ for other in ["/ShadingType 1", "/ShadingType 3", "/Subtype /Image"] {
+ assert!(!text.contains(other), "{case}: {other}");
+ }
+ assert!(warnings.iter().any(|w| w.contains("diamond gradients are exported as four linear gradients")), "{case}: {warnings:?}");
+ let back = import(&export(&d, &o).unwrap()).unwrap();
+ assert_similar(&render_artboard(&back), &render_artboard(&d), 4.0, 0.01);
+ }
+}
+
+/// A Perceptual diamond of one spot ink running out to paper white is not drawn as pixels: its
+/// four quadrants are axial shadings in that ink's Separation space, as a Perceptual linear
+/// gradient of spot inks is.
+#[test]
+fn a_perceptual_diamond_of_a_spot_ink_is_four_separation_shadings() {
+ let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1);
+ let mut stops = vec![];
+ for (offset, tint) in [(0.0, 1.0), (0.5, 0.4)] {
+ let mut s = GradientStop::new(offset, gold.tinted(tint));
+ (s.swatch, s.tint) = (Some("Gold".into()), tint);
+ stops.push(s);
+ }
+ stops.push(GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0)));
+ let g = Gradient { method: vectorcraft_color::GradientMethod::Perceptual, ..Gradient::new(GradientKind::Diamond, stops) };
+ let mut d = doc(g, placed());
+ d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
+ d.swatches.push(vectorcraft_color::Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true });
+ let (text, warnings) = uncompressed(&d, &PdfOptions::uncompressed());
+ assert_eq!(text.matches("/ShadingType 2").count(), 4, "{text}");
+ assert!(text.contains("[/Separation/Gold"), "{text}");
+ assert!(!text.contains("/Subtype /Image"), "{text}");
+ assert!(warnings.iter().any(|w| w.contains("diamond gradients are exported as four linear gradients")), "{warnings:?}");
+}
+
+/// A dithered fill, a Perceptual gradient of process colors, one with translucent stops (whose
+/// quadrants would add up their opacity where they overlap) and a diamond without an extent are
+/// images of what the canvas draws, with a warning.
+#[test]
+fn a_diamond_drawn_as_pixels_or_without_an_extent_is_an_image() {
+ let c = Point::new(100.0, 100.0);
+ let dithered = Gradient { dither: true, ..rgb_diamond() };
+ let perceptual = Gradient { method: vectorcraft_color::GradientMethod::Perceptual, ..rgb_diamond() };
+ let mut translucent = rgb_diamond();
+ translucent.stops[1].opacity = 0.4;
+ for (case, d) in [
+ ("dithered", doc(dithered, placed())),
+ ("perceptual", doc(perceptual, placed())),
+ ("translucent", doc(translucent, placed())),
+ ("no vector", doc(rgb_diamond(), GradientGeom { start: c, end: c, aspect: 1.0, focal: None })),
+ ("flat", doc(rgb_diamond(), GradientGeom { aspect: 0.0, ..placed() })),
+ ] {
+ let (text, warnings) = uncompressed(&d, &PdfOptions::uncompressed());
+ assert!(text.contains("/Image") && !text.contains("/ShadingType"), "{case}");
+ assert!(warnings.iter().any(|w| w.contains("diamond gradients drawn as pixels")), "{case}: {warnings:?}");
+ let back = import(&export(&d, &PdfOptions::uncompressed()).unwrap()).unwrap();
+ let (want, got) = (render_artboard(&d), render_artboard(&back));
+ for (x, y) in [(150, 120), (100, 160), (40, 100), (100, 40), (90, 110), (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 34af4ea4ce..abb15552da 100644
--- a/crates/pdf/src/tests/import_fidelity.rs
+++ b/crates/pdf/src/tests/import_fidelity.rs
@@ -769,6 +769,47 @@ fn a_function_based_shading_beside_the_origin_becomes_a_conic_gradient() {
}
}
+/// A function-based shading whose calculator function turns `abs(x) + abs(y)` into a gray ramp
+/// (ISO 32000-1 7.10.5; the ramp clipped to 1 past the square with corners at the domain's edges)
+/// comes in as a diamond gradient about the origin's place on the page. A ramp along a circle
+/// about the origin does not.
+#[test]
+fn a_function_based_shading_of_squares_about_the_origin_becomes_a_diamond_gradient() {
+ use vectorcraft_geom::Point;
+ let _held = shadings();
+ let fill = |r: &ImportReport| {
+ all(&r.document).into_iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
+ Some(Paint::Gradient(g)) => Some(g.clone()),
+ _ => None,
+ })
+ };
+ // 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", "{ abs exch abs add dup dup }");
+ let g = fill(&r).unwrap_or_else(|| panic!("a gradient fill, {:?}", r.warnings));
+ assert_eq!(g.gradient.kind, GradientKind::Diamond);
+ let geom = g.geom.unwrap();
+ assert!(geom.start.distance(Point::new(50.0, 50.0)) < 0.01, "{geom:?}");
+ for (x, y) in [(50.0, 50.0), (75.0, 50.0), (50.0, 20.0), (70.0, 70.0), (35.0, 60.0), (95.0, 95.0), (10.0, 80.0)] {
+ let gray = g.gradient.sample(geom.param_at(GradientKind::Diamond, Point::new(x, y)) as f32).0.to_rgb()[0];
+ let want = (((x - 50.0) as f32).abs() / 50.0 + ((y - 50.0) as f32).abs() / 50.0).min(1.0);
+ assert!((gray - want).abs() < 0.03, "({x}, {y}): {gray} vs {want}");
+ }
+ // A radial ramp (the distance from the origin) is not one.
+ let r = function_shading("-1 1 -1 1", "50 0 0 50 50 50", "{ dup mul exch dup mul add sqrt dup dup }");
+ assert!(fill(&r).is_none_or(|g| g.gradient.kind != GradientKind::Diamond));
+ // A domain wider than it is tall: the ramp keeps changing out to its far corners, past the
+ // largest square the domain holds whole.
+ let r = function_shading("-2 2 -1 1", "50 0 0 50 50 50", "{ abs exch abs add 3 div dup dup }");
+ let g = fill(&r).unwrap_or_else(|| panic!("a gradient fill, {:?}", r.warnings));
+ assert_eq!(g.gradient.kind, GradientKind::Diamond);
+ let geom = g.geom.unwrap();
+ for (x, y) in [(50.0, 50.0), (125.0, 90.0), (-20.0, 10.0), (140.0, 50.0), (60.0, 95.0), (0.0, 30.0)] {
+ let gray = g.gradient.sample(geom.param_at(GradientKind::Diamond, Point::new(x, y)) as f32).0.to_rgb()[0];
+ let want = (((x - 50.0) as f32).abs() / 50.0 + ((y - 50.0) as f32).abs() / 50.0) / 3.0;
+ assert!((gray - want).abs() < 0.03, "({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 8da21a9c49..0e149579f5 100644
--- a/crates/plugins/src/objects.rs
+++ b/crates/plugins/src/objects.rs
@@ -617,12 +617,17 @@ 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;
+ let no_focal = conic || g.gradient.kind == vectorcraft_color::GradientKind::Diamond;
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)?;
}
+ // Conic and diamond gradients have no focal point (the commands refuse one).
+ if no_focal {
+ geom.focal = None;
+ }
let a = geom.aspect;
// A conic's negative aspect mirrors its sweep, so its sign stays.
geom.aspect = match (a.is_finite(), conic) {
diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs
index 0c4572fff3..a6f3c63f30 100644
--- a/crates/plugins/src/objects/tests.rs
+++ b/crates/plugins/src/objects/tests.rs
@@ -89,6 +89,26 @@ fn a_mirrored_conic_keeps_its_sign_through_a_plug_in() {
}
}
+/// A focal point handed back on a conic or diamond gradient, which have none, is dropped; a
+/// radial's is kept.
+#[test]
+fn only_a_radial_keeps_a_focal_point_through_a_plug_in() {
+ let (d, ids, _) = doc();
+ let a = ids[0].0;
+ let focal = Point::new(3.0, 1.0);
+ for (kind, want) in [("Conic", None), ("Diamond", None), ("Radial", Some(focal))] {
+ 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: 1.0, focal: Some(focal) });
+ let mut p = serde_json::to_value(Paint::Gradient(Box::new(g))).unwrap();
+ p["gradient"]["kind"] = json!(kind);
+ let mut d = d.clone();
+ let out = decode_json(json!({"objects": [{"id": a, "fills": [p]}]})).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().focal, want, "{kind}");
+ }
+}
+
/// 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/dither.rs b/crates/render/src/dither.rs
index 0dd887089b..dfd2f53927 100644
--- a/crates/render/src/dither.rs
+++ b/crates/render/src/dither.rs
@@ -1,6 +1,7 @@
-//! Dithered gradient fills in the renderer.
+//! Dithered gradient fills, and diamond gradients, in the renderer.
//!
-//! A dithered linear or radial gradient is drawn as pixels: the gradient sampled on a grid over
+//! A dithered linear or radial gradient, and a diamond gradient (which has no vector gradient to
+//! draw it), is drawn as pixels: the gradient sampled on a grid over
//! the painted box with a cell per device pixel or finer (in quarter-octave steps, so a pan reuses
//! the grid and a zoom rarely misses), each colour channel given fine noise of up to a level
//! ([`vectorcraft_color::gradient::dither_noise`]) before it is rounded, and drawn as an image
@@ -50,18 +51,19 @@ fn cells(px: f64) -> u16 {
2f64.powf(steps / 4.0).ceil().clamp(1.0, MAX_CELLS) as u16
}
-/// Cache key of gradient `g` placed on box `b` as a `cols` × `rows` grid, cells `win` of it,
-/// painted with `ink` (and the active colour settings, which turn colours into display RGB or
-/// inks).
-fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: &[Range; 2], ink: Ink) -> u64 {
+/// Cache key of gradient `g` placed on box `place`, sampled as a `cols` × `rows` grid over box
+/// `b`, cells `win` of it, painting a fill (`fill`) with `ink` (and the active color settings,
+/// which turn colors into display RGB or inks).
+#[allow(clippy::too_many_arguments)]
+fn key(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: &[Range; 2], ink: Ink, fill: bool) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let gr = &g.gradient;
- (gr.kind as u8, gr.dither, gr.method as u8).hash(&mut h);
+ (gr.kind as u8, gr.dither && fill, gr.method as u8).hash(&mut h);
for s in &gr.stops {
(s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h);
s.color.key().hash(&mut h);
}
- let geom = g.resolve(b);
+ let geom = g.resolve(place);
(geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h);
geom.focal.map(|f| (f.x.to_bits(), f.y.to_bits())).hash(&mut h);
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
@@ -89,15 +91,17 @@ fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[
Some([span(seen.x0, seen.x1, b.x0, b.width(), cols)?, span(seen.y0, seen.y1, b.y0, b.height(), rows)?])
}
-/// Cells `win` (columns, rows) of `g` placed on `b` as a `cols` × `rows` grid, as a
-/// premultiplied pixmap, colours as `ink` paints them.
-pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [Range; 2], ink: Ink) -> Pixmap {
+/// Cells `win` (columns, rows) of `g` placed on `place`, sampled as a `cols` × `rows` grid over
+/// `b`, as a premultiplied pixmap, colors as `ink` paints them, with dither's noise when it
+/// paints a fill (`fill`).
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn rasterize(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: [Range; 2], ink: Ink, fill: bool) -> Pixmap {
let (w, h) = (win[0].len() as u16, win[1].len() as u16);
let adjusted = ink.gradient(&g.gradient).map(|gradient| GradientPaint { gradient, ..g.clone() });
let ink = if adjusted.is_some() { ink.base() } else { ink };
let g = adjusted.as_ref().unwrap_or(g);
let data = g
- .pixels_within(b, b, cols, rows, win, true, &|c| ink.rgb(c))
+ .pixels_within(place, b, cols, rows, win, fill, &|c| ink.rgb(c))
.map(|[r, gr, bl, a]| {
let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a }
@@ -106,11 +110,14 @@ pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [
Pixmap::from_parts(data, w, h)
}
-/// Set dithered gradient `g` on box `bounds` (in the paint's space, which the context's current
-/// transform maps to pixels) as the context paint. Returns false for an empty box or one off the
-/// render target.
-pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
- let Some(b) = painted_box(bounds) else { return false };
+/// Set gradient `g` placed on box `bounds` (in the paint's space, which the context's current
+/// transform maps to pixels) as the context paint, drawn as pixels over that box and box `area`
+/// (a stroke's outline, whose miters, caps and arrowheads can reach past `bounds`), with dither's
+/// noise when it paints a fill (`fill`). Returns false for an empty box or one off the render
+/// target. Past both boxes, the pixels at their edge continue.
+pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, area: Rect, ink: Ink, fill: bool) -> bool {
+ let Some(place) = painted_box(bounds) else { return false };
+ let b = Some(place.union(area.abs())).filter(|r| r.is_finite()).unwrap_or(place);
// Device pixels per unit along the box's sides.
let [m0, m1, m2, m3, _, _] = ctx.transform().as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
@@ -121,7 +128,7 @@ pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
let (cols, rows) = (cells(wpx), cells(hpx));
let Some(win) = window(*ctx.transform(), ctx.width(), ctx.height(), b, cols, rows) else { return false };
let (first_col, first_row) = (win[0].start, win[1].start);
- let k = key(g, b, cols, rows, &win, ink);
+ let k = key(g, place, b, cols, rows, &win, ink, fill);
let pm = GRIDS
.with(|c| {
let mut c = c.borrow_mut();
@@ -129,7 +136,7 @@ pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
c.get(&k, t).cloned()
})
.unwrap_or_else(|| {
- let pm = Arc::new(rasterize(g, b, cols, rows, win, ink));
+ let pm = Arc::new(rasterize(g, place, b, cols, rows, win, ink, fill));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs
index e26977c139..dd83e4cee7 100644
--- a/crates/render/src/paint.rs
+++ b/crates/render/src/paint.rs
@@ -46,6 +46,8 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
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())
}
+ // peniko has no diamond gradient: it is drawn as pixels, as a dithered fill is.
+ GradientKind::Diamond => return crate::dither::set_dithered_paint(ctx, g, bounds, bounds, f.ink, false),
_ => {
ctx.reset_paint_transform();
let (s, e) =
@@ -67,7 +69,19 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
pub(crate) fn set_fill_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Frame) -> bool {
match p {
Paint::Gradient(g) if g.gradient.dither && g.gradient.kind != GradientKind::Freeform && !g.gradient.stops.is_empty() => {
- crate::dither::set_dithered_paint(ctx, g, bounds, f.ink)
+ crate::dither::set_dithered_paint(ctx, g, bounds, bounds, f.ink, true)
+ }
+ _ => set_paint(ctx, p, bounds, f),
+ }
+}
+
+/// [`set_paint`] for a stroke whose outline (miters, caps and arrowheads included) fits the box
+/// `area` gives: a diamond gradient, drawn as pixels, is sampled over that box too, so the parts
+/// of the outline past `bounds` get the diamond's colors there. `area` runs only for a diamond.
+pub(crate) fn set_stroke_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, area: impl FnOnce() -> Rect, f: &Frame) -> bool {
+ match p {
+ Paint::Gradient(g) if g.gradient.kind == GradientKind::Diamond && !g.gradient.stops.is_empty() => {
+ crate::dither::set_dithered_paint(ctx, g, bounds, area(), f.ink, false)
}
_ => set_paint(ctx, p, bounds, f),
}
diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs
index 00204f8f1c..2bf0d165db 100644
--- a/crates/render/src/renderer/paint.rs
+++ b/crates/render/src/renderer/paint.rs
@@ -144,15 +144,27 @@ impl Renderer {
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
if let Some(g) = st.path_gradient() {
self.fill_path_gradient(ctx, f, bp, rule, st, chars, &pieces, width, &g.gradient);
- } else if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
- self.fold_alpha(ctx, f.ink, &st.paint);
- ctx.set_fill_rule(peniko::Fill::NonZero);
- match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
- Some(o) => ctx.fill_path(&o),
- None => ctx.fill_path(&crate::stroke::outline(&pieces.line, st, width, f.px * 0.25)),
- }
- for head in &pieces.heads {
- ctx.fill_path(&head.outline);
+ } else {
+ let line = match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
+ Some(o) => o,
+ None => Arc::new(crate::stroke::outline(&pieces.line, st, width, f.px * 0.25)),
+ };
+ // Miters, caps and heads can reach past the paint box: a paint drawn as pixels covers
+ // them too.
+ let reach = || {
+ std::iter::once(&*line)
+ .chain(pieces.heads.iter().map(|h| &h.outline))
+ .filter(|o| !o.elements().is_empty())
+ .map(|o| o.bounding_box())
+ .fold(paint_bounds, |r, b| r.union(b))
+ };
+ if paint::set_stroke_paint(ctx, &st.paint, paint_bounds, reach, f) {
+ self.fold_alpha(ctx, f.ink, &st.paint);
+ ctx.set_fill_rule(peniko::Fill::NonZero);
+ ctx.fill_path(&line);
+ for head in &pieces.heads {
+ ctx.fill_path(&head.outline);
+ }
}
}
if inside {
diff --git a/crates/render/tests/it/diamond.rs b/crates/render/tests/it/diamond.rs
new file mode 100644
index 0000000000..e3092f45c3
--- /dev/null
+++ b/crates/render/tests/it/diamond.rs
@@ -0,0 +1,117 @@
+//! Diamond gradients: the pixels run from the center out to a square with a corner at the end of
+//! the vector, and follow the model's parameter when the square is stretched or turned, on fills
+//! and strokes; a stroke gets no dither noise, and a diamond without an extent draws its last stop.
+
+use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
+use vectorcraft_doc::{Appearance, Document};
+use vectorcraft_geom::{Point, Rect, Vec2, shapes};
+use vectorcraft_testkit::fixtures::DocBuilder;
+use vectorcraft_testkit::raster::{Image, render_view};
+
+/// A white-to-black diamond on `geom`, dithered when `dither`.
+fn paint(geom: GradientGeom, dither: bool) -> Paint {
+ let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Diamond, dither, ..Gradient::default() });
+ gp.geom = Some(geom);
+ Paint::Gradient(Box::new(gp))
+}
+
+/// A 100 pt square filled with `fill` and stroked `width` wide with `stroke`.
+fn square(fill: Paint, stroke: Paint, width: f64) -> Document {
+ let mut b = DocBuilder::new(100.0, 100.0);
+ b.path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(fill, stroke, width), |_| {});
+ b.build()
+}
+
+/// Every 7th pixel inside `inside` has the white-to-black ramp's gray at the model's parameter
+/// there (pixel centers).
+fn assert_follows_model(img: &Image, geom: &GradientGeom, inside: Rect) {
+ 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);
+ if !inside.contains(p) {
+ continue;
+ }
+ let t = geom.param_at(GradientKind::Diamond, p).clamp(0.0, 1.0);
+ 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 a_diamond_fill_follows_the_squares() {
+ let c = Point::new(50.0, 50.0);
+ let level = GradientGeom { start: c, end: c + Vec2::new(40.0, 0.0), aspect: 1.0, focal: None };
+ let stretched = GradientGeom { aspect: 0.5, ..level };
+ let turned = GradientGeom { end: c + Vec2::new(30.0, 30.0), aspect: 0.7, ..level };
+ for geom in [level, stretched, turned] {
+ let img = render_view(&square(paint(geom, false), Paint::None, 0.0), 100, 100);
+ assert_follows_model(&img, &geom, Rect::new(12.0, 12.0, 88.0, 88.0));
+ }
+ // Level: white at the center, mid gray halfway to a corner of the square, along an axis and
+ // along a diagonal alike (pixel centers 20 pt from the center, counted along the axes).
+ let img = render_view(&square(paint(level, false), Paint::None, 0.0), 100, 100);
+ assert!(img.over_white(50, 50)[0] > 240, "{:?}", img.over_white(50, 50));
+ for (x, y) in [(69, 49), (49, 69), (60, 59), (39, 40)] {
+ assert!(img.over_white(x, y)[0].abs_diff(128) <= 6, "({x},{y}): {:?}", img.over_white(x, y));
+ }
+}
+
+/// A stroke follows the squares too, without the noise dither puts on fills.
+#[test]
+fn a_diamond_stroke_follows_the_squares_without_dither() {
+ let c = Point::new(50.0, 50.0);
+ let geom = GradientGeom { start: c, end: c + Vec2::new(60.0, 0.0), aspect: 1.0, focal: None };
+ let doc = square(Paint::None, paint(geom, true), 6.0);
+ let img = render_view(&doc, 100, 100);
+ // Along the top edge (y 10, inside the stroke's inner half), the gray follows the model and
+ // two pixels the same distance from the center are the same.
+ for x in [20, 35, 50, 65, 80] {
+ let p = Point::new(x as f64 + 0.5, 10.5);
+ let want = ((1.0 - geom.param_at(GradientKind::Diamond, p).clamp(0.0, 1.0)) * 255.0).round() as u8;
+ assert!(img.over_white(x, 10)[0].abs_diff(want) <= 6, "x {x}: {:?} vs {want}", img.over_white(x, 10));
+ }
+ assert_eq!(img.over_white(30, 10), img.over_white(69, 10));
+}
+
+/// A stroke's miter reaches past its paint box (the path's box grown by half the weight): the
+/// diamond's grays run on there, as the model gives them, instead of repeating the box's edge.
+#[test]
+fn a_diamond_stroke_follows_the_squares_past_its_paint_box() {
+ let c = Point::new(50.0, 50.0);
+ let geom = GradientGeom { start: c, end: c + Vec2::new(45.0, 0.0), aspect: 1.0, focal: None };
+ let mut ap = Appearance::basic(Paint::None, paint(geom, false), 8.0);
+ for it in &mut ap.items {
+ if let vectorcraft_doc::AppearanceItem::Stroke(s) = it {
+ s.miter_limit = 10.0;
+ }
+ }
+ let mut b = DocBuilder::new(100.0, 100.0);
+ let peak = vectorcraft_geom::SubPath::polyline(&[Point::new(20.0, 80.0), Point::new(50.0, 20.0), Point::new(80.0, 80.0)], false);
+ b.path(vectorcraft_geom::PathData::single(peak), ap, |_| {});
+ let img = render_view(&b.build(), 100, 100);
+ // The miter's tip runs up to y 11.06; the paint box starts at y 16. Rows 13 and 14 are
+ // covered across pixel column 50.
+ for y in [13, 14] {
+ let p = Point::new(50.5, y as f64 + 0.5);
+ let want = ((1.0 - geom.param_at(GradientKind::Diamond, p).clamp(0.0, 1.0)) * 255.0).round() as u8;
+ assert!(img.over_white(50, y)[0].abs_diff(want) <= 6, "y {y}: {:?} vs {want}", img.over_white(50, y));
+ }
+}
+
+/// Without an extent (no vector, or a flat square) every point is past the square: the last stop.
+#[test]
+fn a_diamond_without_an_extent_draws_its_last_stop() {
+ 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: c + Vec2::new(30.0, 0.0), aspect: 0.0, focal: None },
+ GradientGeom { start: c, end: c + Vec2::new(f64::INFINITY, 0.0), aspect: 1.0, focal: None },
+ ] {
+ let img = render_view(&square(paint(geom, false), Paint::None, 0.0), 100, 100);
+ for (x, y) in [(50, 50), (20, 80), (85, 15)] {
+ assert!(img.over_white(x, y)[0] < 6, "{geom:?} ({x},{y}): {:?}", img.over_white(x, y));
+ }
+ }
+}
diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs
index 9059c3a218..9a9e059fba 100644
--- a/crates/render/tests/it/main.rs
+++ b/crates/render/tests/it/main.rs
@@ -4,6 +4,7 @@
mod conic;
mod dash_align;
+mod diamond;
mod golden;
mod gradient_alpha;
mod gradient_text;
diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs
index a60b7d4045..ada9a4375c 100644
--- a/crates/svg/src/css.rs
+++ b/crates/svg/src/css.rs
@@ -555,8 +555,8 @@ impl Rules<'_> {
}
}
- /// 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.
+ /// A linear, radial, conic or diamond 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);
let stops = |pos: &dyn Fn(f32) -> f64| {
@@ -580,9 +580,12 @@ impl Rules<'_> {
let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 };
Some(format!("linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos)))
}
- GradientKind::Radial => {
+ // CSS has no diamond gradient: the ellipse through the square's corners stands in.
+ GradientKind::Radial | GradientKind::Diamond => {
let r = (geom.end - geom.start).hypot();
- if geom.focal.is_some() {
+ if g.gradient.kind == GradientKind::Diamond {
+ self.warn("diamond gradients are written as radial gradients through their corners");
+ } else if geom.focal.is_some() {
self.warn("radial gradients start at their centre (an off-centre focal point is left out)");
}
let shape = if (geom.aspect - 1.0).abs() < 1e-9 {
diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs
index 746ab84d21..3ab4003d7d 100644
--- a/crates/svg/src/export/nodes.rs
+++ b/crates/svg/src/export/nodes.rs
@@ -226,7 +226,7 @@ impl Writer<'_> {
if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) {
p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3))));
p.extend(blend);
- self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true);
+ self.raster_paint(g, b, b, &[d.to_string()], rule, &p, "", true);
} else {
self.fill_props(f, rule, bounds, &mut p);
p.extend(blend);
@@ -263,13 +263,19 @@ impl Writer<'_> {
let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf);
p.extend(opacity);
p.extend(blend);
- self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false);
+ let reach =
+ if outline.elements().is_empty() { b } else { b.union(vectorcraft_geom::kurbo::Shape::bounding_box(&outline)) };
+ self.raster_paint(g, b, reach, &[clip], FillRule::NonZero, &p, &side, false);
}
(WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => {
let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
p.extend(opacity);
p.extend(blend);
- self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false);
+ let reach = outlines
+ .iter()
+ .filter(|o| !o.elements().is_empty())
+ .fold(b, |r, o| r.union(vectorcraft_geom::kurbo::Shape::bounding_box(o)));
+ self.raster_paint(g, b, reach, &clip, FillRule::NonZero, &p, &side, false);
}
(WrittenShape::Stroke { width }, _) => {
p.push(("fill", "none".into()));
diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs
index 89fafe50a4..e982ab315c 100644
--- a/crates/svg/src/export/paint.rs
+++ b/crates/svg/src/export/paint.rs
@@ -269,13 +269,23 @@ impl Writer<'_> {
// `raster_paint`).
self.warn("conic gradients on characters are written as linear gradients");
}
+ if g.gradient.kind == GradientKind::Diamond {
+ // Object fills and strokes are four linear gradients or images (see `raster_paint`).
+ self.warn("diamond gradients on characters are written as radial gradients");
+ }
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
+ if g.gradient.kind != GradientKind::Radial {
+ // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can
+ // carry one that draws nothing).
+ geom.focal = None;
+ }
let (s, e) = (geom.start, geom.end);
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y));
let (tag, prefix, attrs) = match g.gradient.kind {
- GradientKind::Radial => {
+ // A diamond's circle runs through its square's corners.
+ GradientKind::Radial | GradientKind::Diamond => {
let r = self.num((e - s).hypot());
let attrs = if (geom.aspect - 1.0).abs() < 1e-9 {
let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default();
@@ -432,10 +442,12 @@ impl Writer<'_> {
/// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is
/// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a
- /// 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
+ /// stroke: an `` over its painted box and over `area` (a stroke's outline, whose miters,
+ /// caps and arrowheads can reach past the box), clipped to `clip` (outlines in user space
+ /// filled by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's
+ /// image covers the painted box only. 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, radial or conic gradient at the document's raster effects resolution
+ /// in points); a linear, radial, conic or diamond 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.
///
@@ -444,16 +456,42 @@ impl Writer<'_> {
/// 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.
+ ///
+ /// A diamond gradient (SVG has none) with opaque stops that isn't drawn as pixels is written
+ /// as four linear gradients in the same clip group, one for each quadrant of its frame and
+ /// equal to the diamond there, overlapping so no hairline shows between them
+ /// ([`vectorcraft_color::GradientGeom::diamond_quadrants`]). With translucent stops, where
+ /// the overlaps would add up their opacity, or without an extent, 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) {
+ pub(super) fn raster_paint(
+ &mut self,
+ g: &GradientPaint,
+ bounds: Rect,
+ area: 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;
+ // The box the paint covers: the painted box, and a stroke's outline, whose miters, caps
+ // and arrowheads can reach past it (a freeform gradient's field covers the painted box).
+ let cover = if freeform { b } else { Some(b.union(area.abs())).filter(|r| r.is_finite()).unwrap_or(b) };
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 opaque = g.gradient.stops.iter().all(|s| s.opacity >= 1.0);
+ let wedges = conic && !g.draws_as_pixels(fill) && opaque;
+ let quadrants = (g.gradient.kind == GradientKind::Diamond && !g.draws_as_pixels(fill) && opaque)
+ .then(|| g.resolve(bounds).diamond_quadrants(cover))
+ .flatten();
let href = if wedges {
self.warn("conic gradients are written as solid wedges clipped to their shapes");
None
+ } else if quadrants.is_some() {
+ self.warn("diamond gradients are written as four linear gradients clipped to their shapes");
+ None
} else {
let (rgba, cols, rows): (Vec, u16, u16) = if freeform {
let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
@@ -463,8 +501,8 @@ impl Writer<'_> {
} 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 (cols, rows) = (along(cover.width()), along(cover.height()));
+ (g.pixels(b, cover, 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 };
@@ -475,6 +513,8 @@ impl Writer<'_> {
"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 if g.gradient.kind == GradientKind::Diamond {
+ "diamond gradients with translucent stops, dither, the Perceptual method or no extent are written as images clipped to their shapes"
} else {
"linear and radial gradients drawn as pixels are written as images clipped to their shapes"
});
@@ -499,20 +539,32 @@ impl Writer<'_> {
self.depth += 1;
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 (x, y, w, h) = (self.num(cover.x0), self.num(cover.y0), self.num(cover.width()), self.num(cover.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()));
+ None => match quadrants {
+ Some(quadrants) => {
+ let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..g.gradient.clone() };
+ for (pts, geom) in quadrants {
+ // Not named after the swatch: four defs, none of them the swatch's gradient.
+ let linear = GradientPaint { gradient: gradient.clone(), geom: Some(geom), swatch: None, ..g.clone() };
+ let id = self.gradient_def(&linear, b);
+ let d = self.path_d(&PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)), self.xf);
+ self.line(&format!(""));
+ }
}
- }
+ None => {
+ let sample = g.gradient.sampler();
+ for (pts, t) in g.resolve(bounds).conic_wedges(cover, 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("");
@@ -700,18 +752,19 @@ impl Writer<'_> {
}
}
-/// Most pixels along a side of a linear, radial or conic gradient's image.
+/// Most pixels along a side of a linear, radial, conic or diamond 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) or is a conic
-/// gradient (SVG has none either: [`Writer::raster_paint`] draws it as wedges or an image).
+/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one) or is a conic or
+/// diamond gradient (SVG has neither: [`Writer::raster_paint`] draws a conic as wedges or an
+/// image, a diamond as four linear gradients 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) || g.gradient.kind == GradientKind::Conic => Some(g),
+ Paint::Gradient(g) if g.draws_as_pixels(fill) || matches!(g.gradient.kind, GradientKind::Conic | GradientKind::Diamond) => Some(g),
_ => None,
}
}
diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs
index e083d3f03a..0c1af2d287 100644
--- a/crates/svg/tests/it/css_rules.rs
+++ b/crates/svg/tests/it/css_rules.rs
@@ -317,6 +317,24 @@ fn conic_gradients_give_css_conic_gradients() {
assert!(sheet.warnings.iter().any(|w| w.contains("conic gradients are written circular")), "{:?}", sheet.warnings);
}
+/// CSS has no diamond gradient: a diamond is the radial gradient through its square's corners (its
+/// vector, and the aspect across it), at its center, with a warning.
+#[test]
+fn diamond_gradients_give_css_radial_gradients_through_their_corners() {
+ let mut d = doc();
+ let mut g = GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() });
+ g.geom = Some(GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(70.0, 30.0), aspect: 0.5, focal: None });
+ let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
+ n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into();
+ let id = add(&mut d, n);
+ let sheet = css_rules(&d, &[id], &CssOptions::default());
+ assert!(sheet.warnings.iter().any(|w| w.contains("diamond gradients are written as radial gradients")), "{:?}", sheet.warnings);
+ assert_eq!(
+ prop(&one(&d, id, &CssOptions::default()), "background-image"),
+ Some("radial-gradient(ellipse 30px 15px at 40px 30px, #ffffff 0%, #000000 100%)")
+ );
+}
+
#[test]
fn shadows_and_unsupported_art() {
let mut d = doc();
diff --git a/crates/svg/tests/it/diamond.rs b/crates/svg/tests/it/diamond.rs
new file mode 100644
index 0000000000..822cf5faf1
--- /dev/null
+++ b/crates/svg/tests/it/diamond.rs
@@ -0,0 +1,146 @@
+//! Diamond gradients, which SVG has no element for: fills and strokes of opaque stops are written
+//! as four linear gradients, one for each quadrant of the diamond's frame, 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.
+
+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` (red to blue when `None`), from (100, 80) out to a square turned and stretched
+/// across its vector.
+fn diamond(gradient: Option) -> Paint {
+ let ramp = Gradient::new(
+ GradientKind::Diamond,
+ 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.unwrap_or(ramp));
+ g.geom = Some(GradientGeom { start: Point::new(100.0, 80.0), end: Point::new(150.0, 50.0), aspect: 0.7, focal: None });
+ g.swatch = Some("Gem".into());
+ 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_diamond_fill_is_four_linear_gradients_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(diamond(None), Paint::None, 0.0)));
+ let o = export_full(&d, &ExportOptions::default(), None);
+ assert_eq!(o.svg.matches(" Self {
Self { geom, gradient: Gradient::new(GradientKind::Linear, stops.to_vec()) }
}
- /// Placed by a center and an extent ellipse (radial and conic)?
+ /// Placed by a center and an extent ellipse (radial and conic) or square (diamond)?
fn radial(&self) -> bool {
self.gradient.kind.is_centered()
}
@@ -116,11 +124,12 @@ impl Annotator {
pub fn aspect_point(&self) -> Point {
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).
+ /// How far `p` is from a radial's extent ellipse, or a diamond's square (approximately, along
+ /// the ray from the center).
fn off_ellipse(&self, p: Point) -> Option {
let m = self.geom.unit_frame()?;
- let rho = (m.inverse() * p).to_vec2().hypot();
+ let q = (m.inverse() * p).to_vec2();
+ let rho = if self.gradient.kind == GradientKind::Diamond { q.x.abs() + q.y.abs() } else { q.hypot() };
(rho > 1e-12).then(|| p.distance(self.geom.start) * (1.0 - 1.0 / rho).abs())
}
fn vector(&self) -> Vec2 {
@@ -249,14 +258,19 @@ pub struct GradientTool {
}
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop; a
-/// radial's extent ellipse with its aspect handle, centre ring and focal dot.
+/// radial's extent ellipse (a diamond's square) with its aspect handle, center ring and focal dot.
fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec {
let (s, e) = (a.geom.start, a.geom.end);
let shade = Vec2::new(0.0, cx.tol(1.0));
let mut o = vec![];
if let Some(m) = a.geom.unit_frame().filter(|_| a.radial()) {
// Dark dashes over a light line: readable on any art.
- let path = vectorcraft_geom::kurbo::Ellipse::from_affine(m).to_path(cx.tol(0.25));
+ let path = if a.gradient.kind == GradientKind::Diamond {
+ // A diamond's extent is its square, a corner at the end of the vector.
+ super::polygon(&[m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0), m * Point::new(-1.0, 0.0), m * Point::new(0.0, -1.0)], true)
+ } else {
+ vectorcraft_geom::kurbo::Ellipse::from_affine(m).to_path(cx.tol(0.25))
+ };
o.push(Overlay::Path { path: path.clone(), color: LIGHT, width: 1.0, dashed: false });
o.push(Overlay::Path { path, color: BAR, width: 1.0, dashed: true });
o.push(Overlay::Handle { p: a.aspect_point(), color: BAR });
@@ -331,7 +345,9 @@ impl GradientTool {
Some((a, Part::End)) => Grab::End(a.geom),
Some((a, Part::Rotate)) => Grab::Rotate { geom: a.geom, from: (p - a.geom.start).atan2() - a.vector().atan2() },
Some((a, Part::Focal)) => Grab::Focal(a.geom),
- Some((a, Part::Aspect)) => Grab::Aspect(a.geom),
+ // Only a conic's negative aspect means a mirror (a file's other kinds draw as its size).
+ Some((a, Part::Aspect)) if a.gradient.kind == GradientKind::Conic => Grab::Aspect(a.geom),
+ Some((a, Part::Aspect)) => Grab::Aspect(GradientGeom { aspect: a.geom.aspect.abs(), ..a.geom }),
None => {
if cx.selection.is_empty() {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs
index 436470a2d7..943ed3af7b 100644
--- a/crates/tools/src/xform/gradient/tests.rs
+++ b/crates/tools/src/xform/gradient/tests.rs
@@ -397,3 +397,59 @@ fn a_conic_shows_its_extent_ellipse_and_aspect_handle_without_a_focal_dot() {
assert!((v["aspect"].as_f64().unwrap() - want).abs() < 1e-9, "{aspect}: {v}");
}
}
+
+/// A diamond shows its square (a corner at the end of the vector) with the aspect handle and
+/// center ring of a radial, without a focal dot. Dragging the aspect handle of one a file gave a
+/// negative aspect sets a positive one.
+#[test]
+fn a_diamond_shows_its_square_and_aspect_handle() {
+ 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::Diamond;
+ g.geom.as_mut().unwrap().aspect = aspect;
+ let geom = g.geom.unwrap();
+ n.appearance.set_fill(Paint::Gradient(g));
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let o = GradientTool::default().overlays(&cx);
+ let squares: Vec<_> = o.iter().filter_map(|o| if let Overlay::Path { path, dashed: true, .. } = o { Some(path) } else { None }).collect();
+ assert_eq!(squares.len(), 1, "the square");
+ // Four straight sides through the corners: the end of the vector, the aspect handle and
+ // their mirrors about the center.
+ let corners: Vec = squares[0].elements().iter().filter_map(|e| e.end_point()).collect();
+ let u = geom.end - geom.start;
+ let w = Vec2::new(u.y, -u.x) * 0.5;
+ for want in [geom.end, geom.start - w, geom.start - u, geom.start + w] {
+ assert!(corners.iter().any(|c| c.distance(want) < 1e-9), "{aspect}: {want:?} in {corners:?}");
+ }
+ assert!(!squares[0].elements().iter().any(|e| matches!(e, vectorcraft_geom::kurbo::PathEl::CurveTo(..))), "straight sides");
+ assert!(o.contains(&Overlay::Handle { p: Point::new(100.0, 100.0), color: BAR }), "{aspect}: {o:?}");
+ let a = Annotator::of(&cx).unwrap();
+ assert_eq!(a.hit(&cx, Point::new(100.0, 100.0)), Some(Part::Aspect));
+ 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();
+ assert!((v["aspect"].as_f64().unwrap() - 70.0).abs() < 1e-9, "{aspect}: {v}");
+ }
+}
+
+/// Dragging the aspect handle of a radial a file gave a negative aspect (which the commands
+/// refuse) sets a positive one, as for a diamond: only a conic's sign mirrors it.
+#[test]
+fn a_radial_with_a_negative_aspect_drags_to_a_positive_one() {
+ 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::Radial;
+ g.geom.as_mut().unwrap().aspect = -0.5;
+ n.appearance.set_fill(Paint::Gradient(g));
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ assert_eq!(Annotator::of(&cx).unwrap().hit(&cx, Point::new(100.0, 100.0)), Some(Part::Aspect));
+ 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();
+ assert!((v["aspect"].as_f64().unwrap() - 70.0).abs() < 1e-9, "{v}");
+}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 24f16e1794..6a759967bd 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2540,6 +2540,7 @@ digits Malayalam Малаялам
Radial Glows Лъчисти сияния
Radial Gradient Лъчист градиент
Conic Gradient Конусен градиент
+ Diamond Gradient Ромбовиден градиент
Radius 1 Радиус 1
Radius 2 Радиус 2
Radius1 Радиус1
@@ -4537,7 +4538,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, conic, freeform) непознат вид градиент `{k}` (линеен, радиален, конусен, свободна форма)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (линеен, радиален, конусен, ромбовиден, свободна форма)
@msg unknown highlight `{h}` непознат акцент `{h}`
@msg unknown option `{path}.{k}` непозната опция `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (запазване, симулиране или отхвърляне)
@@ -5641,7 +5642,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, radial or conic gradient, not a freeform one span: "selection" поставя линеен, радиален или конусен градиент, а не свободен
+@msg span: "selection" places a linear, radial, conic or diamond 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 83290bdbd9..1f77b54611 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -1667,6 +1667,7 @@ digits Malayalam Malajalam
Points: clicks add free points Tačke: klikovi dodaju slobodne tačke
Radial Gradient Radijalni gradijent
Conic Gradient Konusni gradijent
+ Diamond Gradient Rombni gradijent
Reset to White, Black Vrati na bijelu, crnu
Reverse Gradient Obrni gradijent
Save to Swatches Sačuvaj u uzorke boja
@@ -4936,7 +4937,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, conic, freeform) nepoznata vrsta gradijenta `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) nepoznata vrsta gradijenta `{k}` (linear, radial, conic, diamond, 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)
@@ -5662,7 +5663,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" postavlja linearni, radijalni, konusni ili rombni 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 717c2b59d9..0b5e0c7678 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -3278,6 +3278,7 @@ digits Malayalam Malaiàlam
Radial Glows Resplendors radials
Radial Gradient Degradat radial
Conic Gradient Degradat cònic
+ Diamond Gradient Degradat de rombe
Radial Repeat Repetició radial
Radius Radi
Radius 1 Radi 1
@@ -5794,7 +5795,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" col·loca un degradat lineal, radial, cònic o de rombe, 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`
@@ -5968,7 +5969,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, conic, freeform) tipus de degradat desconegut `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) tipus de degradat desconegut `{k}` (linear, radial, conic, diamond, 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 216579443f..feaa86c61d 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -1691,6 +1691,7 @@ digits Malayalam Malajálamské
Points: clicks add free points Body: kliknutím přidáte volné body
Radial Gradient Kruhový přechod
Conic Gradient Kuželový přechod
+ Diamond Gradient Kosočtverečný 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
@@ -5023,7 +5024,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, conic, freeform) neznámý druh přechodu `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) neznámý druh přechodu `{k}` (linear, radial, conic, diamond, 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)
@@ -5797,7 +5798,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" umísťuje lineární, radiální, kuželový nebo kosočtverečný 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 2339ca27ed..c1afbc0dbc 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -1602,6 +1602,7 @@ digits Malayalam Malayalam
Points: clicks add free points Punkter: klik tilføjer frie punkter
Radial Gradient Radial gradient
Conic Gradient Konisk gradient
+ Diamond Gradient Diamantgradient
Reset to White, Black Nulstil til hvid, sort
Reverse Gradient Omvend gradient
Save to Swatches Gem i farveprøver
@@ -4633,7 +4634,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, conic, freeform) ukendt gradienttype `{k}` (lineær, radial, konisk, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) ukendt gradienttype `{k}` (lineær, radial, konisk, diamant, 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)
@@ -5397,7 +5398,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" placerer en lineær, radial, konisk eller diamantformet 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 e5ad28a0c2..3b127edee0 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2571,6 +2571,7 @@ digits Malayalam Malayalam
Radial Glows Radiale Leuchteffekte
Radial Gradient Radialer Verlauf
Conic Gradient Konischer Verlauf
+ Diamond Gradient Rautenverlauf
Radius 1 Radius 1
Radius 2 Radius 2
Radius1 Radius1
@@ -4632,7 +4633,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, conic, freeform) unbekannte Verlaufsart `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) unbekannte Verlaufsart `{k}` (linear, radial, conic, diamond, 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)
@@ -5375,7 +5376,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, radial or conic gradient, not a freeform one span: "selection" platziert einen linearen, radialen oder konischen Verlauf, keinen Freiform-Verlauf
+@msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" platziert einen linearen, radialen, konischen oder Rautenverlauf, 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 c87ecb5713..43fe00e58c 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -1899,6 +1899,7 @@ font-technology Variable Μεταβλητές
Radial Ακτινικό
Radial Gradient Ακτινωτό ντεγκραντέ
Conic Gradient Κωνικό ντεγκραντέ
+ Diamond Gradient Ρομβοειδές ντεγκραντέ
Random Τυχαίο
Range: Εύρος:
Rasterize Μετατροπή σε εικονοστοιχεία
@@ -4664,7 +4665,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, conic, freeform) άγνωστος τύπος ντεγκραντέ `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) άγνωστος τύπος ντεγκραντέ `{k}` (linear, radial, conic, diamond, 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)
@@ -5762,7 +5763,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, radial or conic gradient, not a freeform one span: το "selection" τοποθετεί γραμμικό, ακτινικό ή κωνικό ντεγκραντέ, όχι ελεύθερης μορφής
+@msg span: "selection" places a linear, radial, conic or diamond 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 db8da4cc35..f21713a8b8 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2613,6 +2613,7 @@ digits Malayalam Malayálam
Radial Glows Resplandores radiales
Radial Gradient Degradado radial
Conic Gradient Degradado cónico
+ Diamond Gradient Degradado de rombo
Radius 1 Radio 1
Radius 2 Radio 2
Radius1 Radio1
@@ -4715,7 +4716,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, conic, freeform) tipo de degradado desconocido `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) tipo de degradado desconocido `{k}` (linear, radial, conic, diamond, 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)
@@ -5739,7 +5740,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" coloca un degradado lineal, radial, cónico o de rombo, 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 c3d7fcfb91..922549c296 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -1664,6 +1664,7 @@ digits Malayalam Malajalam
Points: clicks add free points Pisteet: napsautukset lisäävät vapaita pisteitä
Radial Gradient Säteittäinen liukuväri
Conic Gradient Kartiomainen liukuväri
+ Diamond Gradient Vinoneliöliukuväri
Reset to White, Black Palauta valkoinen, musta
Reverse Gradient Käännä liukuväri
Save to Swatches Tallenna värimalleihin
@@ -4925,7 +4926,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, conic, freeform) tuntematon liukuvärin laji `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) tuntematon liukuvärin laji `{k}` (linear, radial, conic, diamond, 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)
@@ -5774,7 +5775,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" sijoittaa lineaarisen, säteittäisen, kartiomaisen tai vinoneliö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 e557d273c9..e220af6947 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2605,6 +2605,7 @@ digits Malayalam Malayalam
Radial Glows Halos radiaux
Radial Gradient Dégradé radial
Conic Gradient Dégradé conique
+ Diamond Gradient Dégradé en losange
Radius 1 Rayon 1
Radius 2 Rayon 2
Radius1 Rayon1
@@ -4704,7 +4705,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, conic, freeform) type de dégradé inconnu `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) type de dégradé inconnu `{k}` (linear, radial, conic, diamond, 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)
@@ -5792,7 +5793,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" place un dégradé linéaire, radial, conique ou en losange, 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 129dfeea3c..33ec08bada 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -3571,6 +3571,7 @@ antiAlias Sharp शार्प
Radial Glows रेडियल चमक
Radial Gradient रेडियल ग्रेडिएंट
Conic Gradient शंक्वाकार ग्रेडिएंट
+ Diamond Gradient डायमंड ग्रेडिएंट
Radial Repeat रेडियल दोहराव
Radius त्रिज्या
Radius 1 त्रिज्या 1
@@ -7483,7 +7484,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, radial or conic gradient, not a freeform one span: "selection" एक रैखिक, रेडियल या शंक्वाकार ग्रेडिएंट रखता है, फ्रीफॉर्म ग्रेडिएंट नहीं
+@msg span: "selection" places a linear, radial, conic or diamond 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 स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें
@@ -7902,7 +7903,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, conic, freeform) अज्ञात ग्रेडिएंट `{k}` (रैखिक, रेडियल, शंक्वाकार, फ्रीफॉर्म)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, 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 78c096b113..6e4787a66f 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -3162,6 +3162,7 @@ mark-style Japanese Japán
Radial Glows Körkörös ragyogások
Radial Gradient Körkörös színátmenet
Conic Gradient Kúpos színátmenet
+ Diamond Gradient Rombusz színátmenet
Radial Repeat Körkörös ismétlés
Radius Sugár
Radius 1 Sugár 1
@@ -5528,7 +5529,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, conic, freeform) ismeretlen színátmenet-fajta: `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) ismeretlen színátmenet-fajta: `{k}` (linear, radial, conic, diamond, 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)
@@ -5780,7 +5781,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" lineáris, sugaras, kúpos vagy rombusz 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 002740e114..2fc2d212fd 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -1602,6 +1602,7 @@ digits Malayalam Malayalam
Points: clicks add free points Titik: klik menambahkan titik bebas
Radial Gradient Gradien radial
Conic Gradient Gradien kerucut
+ Diamond Gradient Gradien wajik
Reset to White, Black Setel ulang ke putih, hitam
Reverse Gradient Balikkan gradien
Save to Swatches Simpan ke swatch
@@ -4633,7 +4634,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, conic, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, kerucut, bentuk bebas)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, kerucut, wajik, 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)
@@ -5397,7 +5398,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, radial or conic gradient, not a freeform one span: "selection" menempatkan gradien linear, radial, atau kerucut, bukan gradien bebas
+@msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" menempatkan gradien linear, radial, kerucut, atau wajik, 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 7f230b10f4..8f342a3a0b 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2647,6 +2647,7 @@ digits Malayalam Malayalam
Radial Glows Bagliori radiali
Radial Gradient Sfumatura radiale
Conic Gradient Sfumatura conica
+ Diamond Gradient Sfumatura a rombo
Radius 1 Raggio 1
Radius 2 Raggio 2
Radius1 Raggio1
@@ -4758,7 +4759,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, conic, freeform) tipo di sfumatura sconosciuto `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) tipo di sfumatura sconosciuto `{k}` (linear, radial, conic, diamond, 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)
@@ -5410,7 +5411,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" posiziona una sfumatura lineare, radiale, conica o a rombo, 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 88c9013b2e..45263f99de 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2688,6 +2688,7 @@ mark-style Japanese 日本式
Radar Graph Tool レーダーチャートツール
Radial Gradient 円形グラデーション
Conic Gradient 円錐グラデーション
+ Diamond Gradient ひし形グラデーション
Random ランダム
Randomize Colors カラーをランダム化
Randomly change color order カラーの順序をランダムに変更
@@ -4364,7 +4365,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, conic, freeform) `{k}` は不明なグラデーションの種類です (linear、radial、conic、freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) `{k}` は不明なグラデーションの種類です (linear、radial、conic、diamond、freeform)
@msg the art could not be sampled アートをサンプリングできませんでした
@msg the art is too light for any dots アートが明るすぎて網点を生成できません
@msg the document has no symbols ドキュメントにシンボルがありません
@@ -5453,7 +5454,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, radial or conic gradient, not a freeform one span: "selection" は線形、円形または円錐グラデーションを配置します。フリーグラデーションは配置できません
+@msg span: "selection" places a linear, radial, conic or diamond 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 4b7dfdcac7..ab774302c6 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -1536,6 +1536,7 @@ digits Malayalam მალაიალამური
Points: clicks add free points წერტილები: დაწკაპუნებები ამატებს თავისუფალ წერტილებს
Radial Gradient რადიალური გრადიენტი
Conic Gradient კონუსური გრადიენტი
+ Diamond Gradient რომბული გრადიენტი
Reset to White, Black თეთრზე და შავზე დაბრუნება
Reverse Gradient გრადიენტის შებრუნება
Gradient Presets გრადიენტის პრესეტები
@@ -4460,7 +4461,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, conic, freeform) უცნობი გრადიენტის სახე `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) უცნობი გრადიენტის სახე `{k}` (linear, radial, conic, diamond, 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)
@@ -5461,7 +5462,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, radial or conic gradient, not a freeform one span: "selection" ათავსებს წრფივ, რადიალურ ან კონუსურ გრადიენტს და არა თავისუფალი ფორმისას
+@msg span: "selection" places a linear, radial, conic or diamond 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 ac3f3d0cf6..a5bd22ceef 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -2678,6 +2678,7 @@ mark-style Japanese 일본식
Radar Graph Tool 레이더 그래프 도구
Radial Gradient 방사형 그레이디언트
Conic Gradient 원뿔형 그레이디언트
+ Diamond Gradient 다이아몬드 그레이디언트
Random 무작위
Randomize Colors 색상 무작위 변경
Randomly change color order 색상 순서를 무작위로 변경
@@ -4291,7 +4292,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, conic, freeform) 알 수 없는 그레이디언트 종류 `{k}`(linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) 알 수 없는 그레이디언트 종류 `{k}`(linear, radial, conic, diamond, freeform)
@msg the art could not be sampled 아트웍을 샘플링할 수 없습니다
@msg the art is too light for any dots 아트웍이 너무 밝아 점을 만들 수 없습니다
@msg the document has no symbols 문서에 심볼이 없습니다
@@ -5371,7 +5372,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, radial or conic gradient, not a freeform one span: "selection"은 선형, 방사형 또는 원뿔형 그레이디언트를 배치합니다. 자유형 그레이디언트는 배치할 수 없습니다
+@msg span: "selection" places a linear, radial, conic or diamond 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 0622156953..da59f5cf1f 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -1616,6 +1616,7 @@ digits Malayalam Malayalam
Points: clicks add free points Punten: klikken voegen vrije punten toe
Radial Gradient Radiaal verloop
Conic Gradient Conisch verloop
+ Diamond Gradient Ruitverloop
Reset to White, Black Herstellen naar wit, zwart
Reverse Gradient Verloop omkeren
Save to Swatches Opslaan in stalen
@@ -4645,7 +4646,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, conic, freeform) onbekend verlooptype `{k}` (lineair, radiaal, conisch, vrijvorm)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) onbekend verlooptype `{k}` (lineair, radiaal, conisch, ruit, 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)
@@ -5409,7 +5410,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, radial or conic gradient, not a freeform one span: "selection" plaatst een lineair, radiaal of conisch verloop, geen vrij verloop
+@msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" plaatst een lineair, radiaal, conisch of ruitvormig 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 11dd0dc99a..3786dc2365 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -1603,6 +1603,7 @@ digits Malayalam Malajalam
Points: clicks add free points Punkty: kliknięcia dodają wolne punkty
Radial Gradient Gradient promienisty
Conic Gradient Gradient stożkowy
+ Diamond Gradient Gradient rombowy
Reset to White, Black Resetuj do bieli i czerni
Reverse Gradient Odwróć gradient
Save to Swatches Zapisz w próbkach
@@ -4849,7 +4850,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, conic, freeform) nieznany rodzaj gradientu `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) nieznany rodzaj gradientu `{k}` (linear, radial, conic, diamond, 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)
@@ -5650,7 +5651,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, radial or conic gradient, not a freeform one span: "selection" umieszcza gradient liniowy, radialny lub stożkowy, a nie dowolny
+@msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" umieszcza gradient liniowy, radialny, stożkowy lub rombowy, 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 62b2fa772a..f5016de025 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2178,6 +2178,7 @@ digits Malayalam Malaiala
Radial Glows Brilhos Radiais
Radial Gradient Gradiente Radial
Conic Gradient Gradiente Cônico
+ Diamond Gradient Gradiente em Losango
Radius: Raio:
Rainbow Arco-íris
Random Aleatório
@@ -4255,7 +4256,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, conic, freeform) tipo de gradiente desconhecido `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) tipo de gradiente desconhecido `{k}` (linear, radial, conic, diamond, 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)
@@ -5680,7 +5681,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" posiciona um degradê linear, radial, cônico ou em losango, 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 87c359b83b..eb7e174019 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2642,6 +2642,7 @@ digits Malayalam Малаялам
Radial Glows Радиальные свечения
Radial Gradient Радиальный градиент
Conic Gradient Конический градиент
+ Diamond Gradient Ромбовидный градиент
Radius 1 Радиус 1
Radius 2 Радиус 2
Radius1 Радиус1
@@ -4752,7 +4753,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, conic, freeform) неизвестный тип градиента `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) неизвестный тип градиента `{k}` (linear, radial, conic, diamond, 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)
@@ -5404,7 +5405,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, radial or conic gradient, not a freeform one span: "selection" размещает линейный, радиальный или конический градиент, а не произвольный
+@msg span: "selection" places a linear, radial, conic or diamond 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 92de527ff1..b0999b8bfb 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -2550,6 +2550,7 @@ digits Malayalam Malayalam
Radial Glows Radiellt sken
Radial Gradient Radiell gradient
Conic Gradient Konisk gradient
+ Diamond Gradient Rombgradient
Radius 1 Radie 1
Radius 2 Radie 2
Radius1 Radie1
@@ -4613,7 +4614,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, conic, freeform) okänd gradienttyp `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) okänd gradienttyp `{k}` (linear, radial, conic, diamond, 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)
@@ -5766,7 +5767,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" placerar en linjär, radiell, konisk eller rombformad 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 c63e000795..0612d7ca7d 100644
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -2577,7 +2577,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, radial or conic gradient, not a freeform one", &["span"]),
+ ("span: \"selection\" places a linear, radial, conic or diamond 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 e2ec9c6851..db3cd3cfb8 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -2422,6 +2422,7 @@ digits Malayalam Malayalam
Radial Glows Radyal Parıltılar
Radial Gradient Radyal Gradyan
Conic Gradient Konik Gradyan
+ Diamond Gradient Elmas Gradyan
Radius 1 Yarıçap 1
Radius 2 Yarıçap 2
Radius1 Yarıçap1
@@ -4462,7 +4463,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, conic, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, konik, serbest biçimli)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, konik, elmas, 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)
@@ -5473,7 +5474,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" serbest biçimli bir degrade değil, doğrusal, radyal, konik veya elmas 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 235a4d389e..809cb4808e 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2639,6 +2639,7 @@ digits Malayalam Малаялам
Radial Glows Радіальні світіння
Radial Gradient Радіальний градієнт
Conic Gradient Конічний градієнт
+ Diamond Gradient Ромбоподібний градієнт
Radius 1 Радіус 1
Radius 2 Радіус 2
Radius1 Радіус1
@@ -4742,7 +4743,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, conic, freeform) невідомий тип градієнта `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) невідомий тип градієнта `{k}` (linear, radial, conic, diamond, 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)
@@ -5390,7 +5391,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, radial or conic gradient, not a freeform one span: "selection" розміщує лінійний, радіальний або конічний градієнт, а не довільний
+@msg span: "selection" places a linear, radial, conic or diamond 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 87d2c22625..ec2db6c7a6 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2943,6 +2943,7 @@ mark-style Japanese Kiểu Nhật
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
+ Diamond Gradient Chuyển sắc hình thoi
Radius Bán kính
Radius 1 Bán kính 1
Radius 2 Bán kính 2
@@ -5188,7 +5189,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, conic, freeform) loại chuyển sắc không xác định `{k}` (linear, radial, conic, freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) loại chuyển sắc không xác định `{k}` (linear, radial, conic, diamond, 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)
@@ -5391,7 +5392,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, 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 a linear, radial, conic or diamond gradient, not a freeform one span: "selection" đặt dải chuyển màu tuyến tính, xuyên tâm, hình nón hoặc hình thoi, 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 eea8526b99..b63d023484 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2515,6 +2515,7 @@ digits Malayalam 马拉雅拉姆数字
Radial Glows 径向光晕
Radial Gradient 径向渐变
Conic Gradient 圆锥渐变
+ Diamond Gradient 菱形渐变
Radius 1 半径 1
Radius 2 半径 2
Radius1 半径1
@@ -4080,7 +4081,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, conic, freeform) 未知的渐变类型 `{k}` (linear、radial、conic、freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) 未知的渐变类型 `{k}` (linear、radial、conic、diamond、freeform)
@msg the art could not be sampled 无法对图稿取样
@msg the art is too light for any dots 图稿太浅,无法生成任何网点
@msg the document has no symbols 文档中没有符号
@@ -4824,7 +4825,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, radial or conic gradient, not a freeform one span: "selection" 放置线性、径向或圆锥渐变,而不是任意形状渐变
+@msg span: "selection" places a linear, radial, conic or diamond 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 60cc2d1d43..bc13cbbefe 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -1890,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, conic, freeform) 未知的漸層類型 `{k}` (linear、radial、conic、freeform)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) 未知的漸層類型 `{k}` (linear、radial、conic、diamond、freeform)
@msg the art could not be sampled 無法對圖稿取樣
@msg the art is too light for any dots 圖稿太淡,無法產生任何網點
@msg the document has no symbols 文件中沒有符號
@@ -2633,7 +2633,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, radial or conic gradient, not a freeform one span: "selection" 放置線性、放射狀或圓錐漸層,而非任意形狀漸層
+@msg span: "selection" places a linear, radial, conic or diamond 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} 個
@@ -3599,6 +3599,7 @@ digits Malayalam 馬來亞拉姆數字
Radial Glows 放射狀光暈
Radial Gradient 放射狀漸層
Conic Gradient 圓錐漸層
+ Diamond 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 08f1769440..8de3f5e579 100644
--- a/crates/ui-egui/src/icon_data.rs
+++ b/crates/ui-egui/src/icon_data.rs
@@ -90,6 +90,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("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-diamond", include_bytes!("../../../assets/icons/dc-grad-diamond.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 caeded6e83..500649b7ed 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, 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.
+/// The Type row: Linear, Radial, Conic, Diamond 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| {
@@ -134,6 +134,7 @@ pub(crate) fn type_row(app: &mut VectorcraftApp, ui: &mut Ui) {
(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::Diamond, "dc-grad-diamond", tl!("Diamond Gradient")),
(GradientKind::Freeform, "dc-grad-freeform", tl!("Freeform Gradient")),
] {
if widgets::icon_button(ui, icon, tip, kind == Some(k), 24.0).clicked() {
diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs
index d70b112729..27b0eda0b4 100644
--- a/crates/ui-egui/src/panels/gradient/tests.rs
+++ b/crates/ui-egui/src/panels/gradient/tests.rs
@@ -370,6 +370,35 @@ fn the_aspect_field_keeps_a_mirrored_conic_mirrored() {
assert!((geom.aspect + 0.8).abs() < 1e-9 && geom.is_mirrored(), "{geom:?}");
}
+/// The Type row's Diamond button (the fourth of five) makes the gradient a diamond, and the
+/// Aspect Ratio field then edits its aspect.
+#[test]
+fn the_diamond_button_makes_a_diamond_whose_aspect_field_edits_it() {
+ let mut app = app(json!({"gradient": {"kind": "linear", "start": [150, 150], "end": [200, 150]}}));
+ let mut p = Panel::new();
+ let texts = p.frame(&mut app, vec![], Modifiers::NONE);
+ let label = texts.iter().find(|(t, _)| t == "Type:").unwrap_or_else(|| panic!("the Type row: {texts:?}")).1;
+ let buttons = p.row_after(label, 24.0);
+ assert_eq!(buttons.len(), 5, "Linear, Radial, Conic, Diamond and Freeform");
+ p.click(&mut app, buttons[3].center());
+ assert_eq!(fill(&app).gradient.kind, GradientKind::Diamond);
+ let texts = p.frame(&mut app, vec![], Modifiers::NONE);
+ // The aspect field is the first 100% (above Opacity's).
+ let field = texts
+ .iter()
+ .filter(|(t, _)| t == "100%")
+ .map(|(_, at)| *at)
+ .min_by(|a, b| a.y.total_cmp(&b.y))
+ .unwrap_or_else(|| panic!("the aspect field: {texts:?}"));
+ 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 g = fill(&app);
+ assert_eq!(g.gradient.kind, GradientKind::Diamond);
+ assert!((g.geom.unwrap().aspect - 0.8).abs() < 1e-9, "{:?}", g.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 af08770ebe..42ff8b381c 100644
--- a/crates/ui-egui/src/panels/swatches/tiles.rs
+++ b/crates/ui-egui/src/panels/swatches/tiles.rs
@@ -142,6 +142,7 @@ pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bo
GradientKind::Linear => "dc-grad-linear",
GradientKind::Radial => "dc-grad-radial",
GradientKind::Conic => "dc-grad-conic",
+ GradientKind::Diamond => "dc-grad-diamond",
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 d8914ce39f..fea39a4392 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -747,6 +747,18 @@ mod gradient_chip_tests {
assert!(at(&mirrored, 2, 4).abs_diff(64) <= 2 && at(&mirrored, 2, 0).abs_diff(191) <= 2, "{mirrored:?}");
}
+ /// A diamond chip runs from the center out to a square whose edges pass through the chip's
+ /// corners: halfway along an edge and halfway to a corner are the same color.
+ #[test]
+ fn diamond_chip_runs_out_to_the_corners() {
+ let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i].r();
+ let chip = gradient_chip_colors(&paint(GradientKind::Diamond, 0.0), 20.0, 20.0, 4, 4);
+ assert_eq!((at(&chip, 2, 2), at(&chip, 0, 0), at(&chip, 4, 4)), (255, 0, 0), "{chip:?}");
+ for (i, j) in [(2, 0), (0, 2), (1, 1), (3, 3)] {
+ assert!(at(&chip, i, j).abs_diff(128) <= 2, "({i}, {j}): {chip:?}");
+ }
+ }
+
#[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/docs/mcp.md b/docs/mcp.md
index da260b6823..7772d08325 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -462,7 +462,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, radial or conic gradient once on the union of the targets' bounds instead, so one
+`span: "selection"` place a linear, radial, conic or diamond 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
@@ -1085,20 +1085,44 @@ another form gives a warning:
{"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):
+A diamond gradient (`kind: "diamond"`) runs its stops from the center (the start) out to a square with a corner at
+the end of the vector, a diamond on the page while the vector is level; `aspect` stretches the square across the
+vector into a rhombus. Fitted to an object, it is centered on the object's box, and the edge of its square runs
+through the box's farthest corner; at an angle of 0 or 90 degrees the edges run through all four corners. Its
+annotator draws the square with the aspect dot and center ring of a radial gradient; a `focal` or a negative
+`aspect` given for a diamond gradient is an error. Dither and Method apply as they do to a linear gradient, and the
+canvas draws it as pixels, out to the miters, caps and arrowheads of a stroke. PDF, SVG, EPS and legacy `.ai` files
+have no diamond gradient: one of opaque stops is written as four linear gradients, one for each quarter of the
+square, clipped to the shape (PDF as axial shadings). In PDF and SVG, one with translucent stops, a dithered fill or
+a square with no area is an image, and so is a Perceptual one, except in PDF when its stops are spot colors that the
+Method paragraph below describes. SVG writes one on type characters as a radial gradient through the square's
+corners. PDF import reads a function-based shading (type 1) whose color is the same along each square about the
+origin of its space, with corners on its axes, as a diamond gradient. CSS writes a radial gradient through the
+square's corners. A swatch library saved as CSS gets four `linear-gradient` layers, one for each quarter of the box,
+which make a value for the `background` property but not for `background-image`; a swatch whose angle is not a
+multiple of 90 degrees gets a circular `radial-gradient` instead, and, as for a radial swatch, the aspect is not
+written. The `.ai` editing data holds a radial gradient through the corners with the same stops. Object › Expand
+turns it into nested squares (`steps`) or a mesh of rings around the center. A document export that writes it in
+another form gives a warning:
+
+```json
+{"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"kind":"diamond","start":[150,150],"end":[200,150],"aspect":60}}}}
+```
+
+Dither (the Gradient panel's box) draws a linear, radial, conic or diamond gradient fill with fine noise that hides
+banding; SVG and PDF exports write such a fill as an image at the Document Raster Effects resolution. It applies to
+fills (`stroke: true` is refused):
```json
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"dither":true}}}
```
Method (the Gradient panel's menu): `classic` (the default) mixes the colours between stops in display RGB,
-`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, 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
+`perceptual` mixes a linear, radial, conic or diamond 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, radial, conic or diamond 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 (a diamond as four such shadings). 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
or gray inks as a DeviceN shading.
`paint.setFill`/`paint.setStroke` take it in `gradient` too:
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 18d50447ff..410bbad403 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1311,6 +1311,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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`.
+ - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient.
- 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.