Render: radial (circle path) gradients end on the circle around the whole box

An off-centre radial gradient ("from corner", or any fillToRect focus
other than the middle) spread from its focus out to the farthest corner,
so the near side stayed light. Fitted to PowerPoint's export: the first
stop is a point at the focus and the last is the circle through the box's
corners (centre of the box, half its diagonal), with the circles between
moving from one to the other (a two-point radial). A corner focus thus
reaches its last colour at its own corner too. Centred gradients are
unchanged.

Wild synthetic set: off-centre radial background 20.66 -> 1.22, radial
focus grid 6.34 -> 5.69.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 04:54:53 -04:00
parent 1bff83f377
commit d6af3cf573
2 changed files with 51 additions and 20 deletions

View File

@ -60,16 +60,7 @@ pub fn fill_path(
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()),
};
ctx.set_paint(grad);
ctx.fill_path(path);
@ -116,19 +107,31 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()),
}
}
/// A `circle` path gradient over `bounds` with focus rectangle `focus` (`fillToRect` insets, as
/// fractions). As PowerPoint draws it (fitted to its exports): the first stop is a point at the
/// focus (the middle of the focus rectangle) and the last is the circle around the whole box (its
/// centre, half its diagonal), with circles in between moving from one to the other. A focus off
/// the centre so bunches the colours on its near side; a gradient "from a corner" reaches its
/// last colour at that corner too, not only at the far one.
pub fn circle_gradient(focus: [f64; 4], bounds: Rect) -> peniko::Gradient {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
let c = bounds.center();
let r = (bounds.width().hypot(bounds.height()) / 2.0).max(1e-6);
let f = Point::new(fx, fy);
if !(f.is_finite() && c.is_finite() && r.is_finite()) {
return peniko::Gradient::new_radial(c, r as f32);
}
// A focus on or outside the end circle has no circle through it: keep it just inside.
let d = f - c;
let f = if d.hypot() > r * 0.999 { c + d * (r * 0.999 / d.hypot()) } else { f };
peniko::Gradient::new_two_point_radial(f, 0.0, c, r as f32)
}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
/// GIF shows its frame at `gif_time` seconds.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {

View File

@ -161,3 +161,31 @@ fn trimming_a_path_leaves_its_ends_off_by_arc_length() {
let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len());
}
fn radial(focus: [f64; 4]) -> Shape {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
Shape {
id: ShapeId(13),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
fill: Some(Fill::Gradient(Gradient { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true })),
line: no_line(),
..Default::default()
}
}
#[test]
fn a_radial_gradient_off_centre_ends_on_the_circle_around_the_box() {
// Focus a fifth of the way in from the top-left corner. PowerPoint's last colour is reached on
// the circle through the box's corners, so the near corner is dark as well as the far one.
let img = render(vec![radial([0.2, 0.2, 0.8, 0.8])]);
assert!(img.pixel(181, 141)[0] > 240, "white at the focus {:?}", img.pixel(181, 141));
assert!(img.pixel(101, 101)[0] < 60, "near corner dark {:?}", img.pixel(101, 101));
assert!(img.pixel(498, 298)[0] < 60, "far corner dark {:?}", img.pixel(498, 298));
// Straight above the focus the box's edge is well inside the circle: still light.
assert!(img.pixel(181, 101)[0] > 150, "{:?}", img.pixel(181, 101));
// A centred one is symmetric, dark only at the corners.
let img = render(vec![radial([0.5, 0.5, 0.5, 0.5])]);
assert!(img.pixel(300, 200)[0] > 240 && img.pixel(101, 101)[0] < 60);
assert!((img.pixel(110, 200)[0] as i32 - img.pixel(490, 200)[0] as i32).abs() <= 2);
}