From d6af3cf57303be816e2d0abe80ffd343818bf93a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:54:53 -0400 Subject: [PATCH] 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) --- crates/render/src/paint.rs | 43 ++++++++++++++++++--------------- crates/render/src/tests_wild.rs | 28 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 6dd079f..13b4f4f 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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) { diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index f817d8b..5f87c7a 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -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); +}