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); +}