Gradients: conic gradients sweep the stops once around the center

A fourth gradient kind, conic (also accepted as `angle` or `conical`),
sweeps the stops once around the center (the gradient's start),
clockwise on the page from the vector, with a seam where the last stop
meets the first. A negative aspect mirrors the sweep, and reflecting an
object turns its sweep the other way, also for a conic without a
vector or with a flat extent.

- Gradient panel: a Conic button in the Type row; the Aspect Ratio
  field works for conic gradients and keeps a mirrored sweep mirrored.
- Gradient tool: the extent ellipse, aspect handle and center ring of a
  radial gradient, without a focal point.
- Rendering, swatch chips, transforms, warps, plug-in round trips, and
  Object > Expand into wedges or a mesh of columns around the center.
- Commands: a negative `aspect` is an error for linear and radial
  gradients, and so is a `focal` point for a conic gradient.
- PDF export writes a function-based shading whose calculator function
  turns the angle into the ramp, and PDF import reads a function-based
  shading that turns around the origin of its space back as a conic
  gradient, also when that origin lies outside the painted area. A
  PDF/A file, or a conic with more than 97 stops, gets an image
  instead.
- SVG export writes 360 solid wedges clipped to the shape for opaque
  stops and an image otherwise, and a linear gradient on characters.
- CSS export writes `conic-gradient(from ...)`, with the stops mirrored
  for a mirrored sweep.
- EPS and legacy .ai export write 360 solid wedges; the .ai editing data
  holds a linear gradient along the vector with the same stops.
- .af files: conical gradients open as conic gradients.
- An export that writes the gradient in another form gives a warning.

Supersedes #2326 and includes switch87's code from it.

Co-authored-by: Gert Pellin <gert@pellin.be>
This commit is contained in:
dotCooCoo
2026-11-11 15:51:11 -08:00
co-authored by Gert Pellin
parent b08e421445
commit 1b744eae52
81 changed files with 2047 additions and 187 deletions
+22
View File
@@ -67,6 +67,28 @@ fn objects_round_trip_through_the_json_model() {
assert_eq!(d, before);
}
/// A mirrored conic gradient handed back keeps its negative aspect (its sweep stays mirrored),
/// kept within the same range as other aspects; a radial's is kept positive.
#[test]
fn a_mirrored_conic_keeps_its_sign_through_a_plug_in() {
let (d, ids, _) = doc();
let a = ids[0].0;
let fill = |kind: &str, aspect: f64| {
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect, focal: None });
let mut p = serde_json::to_value(Paint::Gradient(Box::new(g))).unwrap();
p["gradient"]["kind"] = json!(kind);
p
};
for (kind, aspect, want) in [("Conic", -0.5, -0.5), ("Conic", -500.0, -100.0), ("Conic", 0.5, 0.5), ("Radial", -0.5, 0.01)] {
let mut d = d.clone();
let out = decode_json(json!({"objects": [{"id": a, "fills": [fill(kind, aspect)]}]})).unwrap();
apply_output(&mut d, &ids, out, None, &keep).unwrap();
let Paint::Gradient(g) = d.node(ids[0]).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
assert_eq!(g.geom.unwrap().aspect, want, "{kind} {aspect}");
}
}
/// A path handed back as it was stays the live shape it was (a rectangle keeps its corner
/// widgets); a path that changed is a plain path.
#[test]