From 23da2fe46190c6d5da3fdc39a6407d57d327db26 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 09:52:57 -0700 Subject: [PATCH] Part of #3187: a gradient mesh made from an ellipse keeps its shape Create Gradient Mesh and a Mesh tool click estimated each border edge's handles from the surface's derivatives, so the mesh's border cut across a curved outline: a 1x1 mesh over a 400 x 300 pt ellipse came out up to 7 pt off it, with corners at its four mesh points. Each border edge now takes the handles of the cubic that best fits the outline between its points, and handles where two border edges meet along a side stay collinear. Deleting a mesh line (Alt-click with the Mesh tool) joined the two edges on either side with a straight line, which turned the ellipse's border into a diamond. The joined edge now keeps the outer handles, grown back to the length they had before the line split the edge. --- crates/doc/src/live.rs | 54 +++++++++++++++ crates/doc/src/live/mesh.rs | 131 +++++++++++++++++++++++++++++++++--- 2 files changed, 177 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304b7fede..cd96cd444 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -233,6 +233,60 @@ mod tests { assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p); } + /// How far the border of `m` strays from the ellipse in `r`, in points (measured across the + /// ellipse's short radius), checked at 20 places along every border edge. + fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 { + let (rows, cols) = (m.rows as usize, m.cols as usize); + let edges = (0..cols).flat_map(|c| [m.edge_u(0, c), m.edge_u(rows, c)]).chain((0..rows).flat_map(|k| [m.edge_v(k, 0), m.edge_v(k, cols)])); + let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); + edges + .flat_map(|e| (0..=20).map(move |i| e.eval(f64::from(i) / 20.0))) + .map(|p| ((p.x - c.x) / a).hypot((p.y - c.y) / b) - 1.0) + .fold(0.0f64, |w, d| w.max(d.abs() * a.min(b))) + } + + /// #3187: a gradient mesh made from an ellipse keeps the ellipse's shape: a Mesh tool click + /// (a 1×1 mesh with lines through the point) and Create Gradient Mesh's 4×4 grid both lay + /// their border on the outline, within about twice the 0.35 pt the outline is flattened to. + /// The 1×1 mesh was up to 7 pt off this 400 × 300 pt ellipse. + #[test] + fn a_mesh_from_an_ellipse_keeps_its_outline() { + const OFF: f64 = 0.75; + let r = Rect::new(100.0, 100.0, 500.0, 400.0); + let e = shapes::ellipse(r); + for (rows, cols) in [(1, 1), (4, 4), (2, 5)] { + let m = GradientMesh::for_path(&e, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let off = mesh_border_off_ellipse(&m, r); + assert!(off < OFF, "{rows}×{cols} mesh: border {off} pt off the ellipse"); + } + let made = GradientMesh::for_path(&e, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let mut m = made.clone(); + // Off centre, so each line splits its edges unevenly. + let i = m.add_lines_at(Point::new(200.0, 330.0)).unwrap(); + let off = mesh_border_off_ellipse(&m, r); + assert!(off < OFF, "after a Mesh tool click: {off} pt"); + // Deleting the lines again (Alt-click) gives the border back as it was. + assert!(m.remove_point_lines(i)); + assert_eq!((m.rows, m.cols), (1, 1)); + for (k, (now, was)) in m.points.iter().zip(&made.points).enumerate() { + let moved = now.handles.iter().zip(&was.handles).map(|(a, b)| (*a - *b).hypot()).fold(0.0, f64::max); + assert!(now.p.distance(was.p) < 1e-6 && moved < 1e-3, "point {k}: {now:?}, made as {was:?}"); + } + // With a handle at the new border point shortened since, the two edges no longer make one + // curve: the merged edge is straight rather than grown past its ends. + let mut m = made.clone(); + let i = m.add_lines_at(Point::new(200.0, 330.0)).unwrap(); + let top = m.idx(0, 1); + m.points[top].handles[H_LEFT] *= 1e-4; + assert!(m.remove_point_lines(i)); + let (a, b) = (m.points[0].p, m.points[1].p); + assert!((m.points[0].handles[H_RIGHT] - (b - a) / 3.0).hypot() < 1e-9, "{:?}", m.points[0].handles); + // A rectangle's mesh keeps straight edges with handles a third of the way along. + let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 60.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let e = m.edge_u(0, 0); + assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}"); + } + #[test] fn mesh_insert_and_remove_lines() { let mut m = diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index e6ba1656f..a87cbd9b4 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -166,6 +166,76 @@ impl Coons { // Gradient meshes // ===================================================================================== +/// The handles, as offsets from `ends`, of the cubic from `ends.0` to `ends.1` that comes closest +/// (least squares) to `side` between `span.0` and `span.1`; `None` when they can't be computed. +/// Each point of the side is matched to the curve's nearest point, refined over a few rounds. +fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(Vec2, Vec2)> { + const N: usize = 32; + const ROUNDS: usize = 6; + let (p0, p3) = (ends.0, ends.1); + let qs: Vec = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect(); + let mut ts: Vec = (1..N).map(|i| i as f64 / N as f64).collect(); + let mut fit = None; + for _ in 0..ROUNDS { + let (mut c11, mut c12, mut c22) = (0.0, 0.0, 0.0); + let (mut x1, mut x2) = (Vec2::ZERO, Vec2::ZERO); + for (q, &t) in qs.iter().zip(&ts) { + let s = 1.0 - t; + let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t); + let r = q.to_vec2() - b0 * p0.to_vec2() - b3 * p3.to_vec2(); + c11 += b1 * b1; + c12 += b1 * b2; + c22 += b2 * b2; + x1 += b1 * r; + x2 += b2 * r; + } + let det = c11 * c22 - c12 * c12; + if det.abs() < 1e-12 { + break; + } + let (p1, p2) = (((c22 * x1 - c12 * x2) / det).to_point(), ((c11 * x2 - c12 * x1) / det).to_point()); + if !(p1.is_finite() && p2.is_finite()) { + break; + } + fit = Some((p1 - p0, p2 - p3)); + // One Newton step towards the nearest point of the curve for each point of the side. + let c = CubicBez::new(p0, p1, p2, p3); + let (a, b, k) = (p1 - p0, p2 - p1, p3 - p2); + for (q, t) in qs.iter().zip(ts.iter_mut()) { + let s = 1.0 - *t; + let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k); + let acc = 6.0 * (s * (b - a) + *t * (k - b)); + let e = c.eval(*t) - *q; + let den = v.hypot2() + e.dot(acc); + if den.abs() > 1e-12 { + *t = (*t - e.dot(v) / den).clamp(0.0, 1.0); + } + } + } + fit +} + +/// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles +/// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer +/// ends: each outer handle grown back by the share of the curve its edge had, which undoes the +/// split [`GradientMesh::insert_row`] and [`GradientMesh::insert_col`] make, so deleting a mesh +/// line keeps the curve of the border it crossed (#3187). The share is the split parameter, read +/// from the lengths of the middle handles. When the edges no longer fit one cubic (a point or a +/// handle was moved since the split), the cubic grown back would not pass through the middle +/// point, and the merged edge is straight. +fn merged_handles(a: (Point, Vec2), mid: (Point, Vec2, Vec2), b: (Point, Vec2)) -> (Vec2, Vec2) { + let straight = ((b.0 - a.0) / 3.0, (a.0 - b.0) / 3.0); + let (la, lb) = (mid.1.hypot(), mid.2.hypot()); + let t = la / (la + lb); + if !(0.01..=0.99).contains(&t) { + return straight; + } + let (ha, hb) = (a.1 / t, b.1 / (1.0 - t)); + let through = CubicBez::new(a.0, a.0 + ha, b.0 + hb, b.0).eval(t); + let tol = 1e-3 * (a.0.distance(mid.0) + mid.0.distance(b.0)).max(1e-9); + if ha.is_finite() && hb.is_finite() && through.distance(mid.0) <= tol { (ha, hb) } else { straight } +} + /// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. fn even_steps(n: u32) -> Vec { let n = n.clamp(1, 200); @@ -283,7 +353,49 @@ impl GradientMesh { /// colour and opacity `color_at` gives at its position (a gradient fill's, say). pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { let coons = Coons::from_path(path)?; - Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p))) + let (us, vs) = (even_steps(cols), even_steps(rows)); + let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)); + m.fit_border(&coons, &us, &vs); + Some(m) + } + + /// Give each edge on the mesh's border the handles that make it follow the outline it stands + /// for, in place of the handles [`Self::from_grid`] estimates from the surface's derivatives + /// (#3187). Where two border edges meet along a side, their handles are made collinear, so the + /// border stays smooth there; the four corners keep the handles their own edges were fitted with. + fn fit_border(&mut self, coons: &Coons, us: &[f64], vs: &[f64]) { + let (rows, cols) = (self.rows as usize, self.cols as usize); + if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 { + return; + } + let edges = [(0, &coons.top), (rows, &coons.bottom)] + .map(|(r, side)| (r, side, true)) + .into_iter() + .chain([(0, &coons.left), (cols, &coons.right)].map(|(c, side)| (c, side, false))); + for (line, side, along_u) in edges { + let (n, steps) = if along_u { (cols, us) } else { (rows, vs) }; + for k in 0..n { + let (a, b, out, back) = if along_u { + (self.idx(line, k), self.idx(line, k + 1), H_RIGHT, H_LEFT) + } else { + (self.idx(k, line), self.idx(k + 1, line), H_DOWN, H_UP) + }; + if let Some((h1, h2)) = fit_edge(side, (steps[k], steps[k + 1]), (self.points[a].p, self.points[b].p)) { + self.points[a].handles[out] = h1; + self.points[b].handles[back] = h2; + } + } + for k in 1..n { + let (i, out, back) = if along_u { (self.idx(line, k), H_RIGHT, H_LEFT) } else { (self.idx(k, line), H_DOWN, H_UP) }; + let h = &mut self.points[i].handles; + let (fwd, bwd) = (h[out], h[back]); + let dir = (fwd.normalize() - bwd.normalize()).normalize(); + if fwd.hypot2() > 0.0 && bwd.hypot2() > 0.0 && dir.is_finite() { + h[out] = dir * fwd.hypot(); + h[back] = -dir * bwd.hypot(); + } + } + } } /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to @@ -792,10 +904,11 @@ impl GradientMesh { let above = self.idx(r - 1, cc); let below = self.idx(r + 1, cc); let mid = self.idx(r, cc); - let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p); - let _ = pm; - self.points[above].handles[H_DOWN] = (pb - pa) / 3.0; - self.points[below].handles[H_UP] = (pa - pb) / 3.0; + let (pa, pb, m) = (self.points[above].p, self.points[below].p, &self.points[mid]); + let hm = (m.p, m.handles[H_UP], m.handles[H_DOWN]); + let (down, up) = merged_handles((pa, self.points[above].handles[H_DOWN]), hm, (pb, self.points[below].handles[H_UP])); + self.points[above].handles[H_DOWN] = down; + self.points[below].handles[H_UP] = up; } self.points.drain(r * w..(r + 1) * w); self.rows -= 1; @@ -806,9 +919,11 @@ impl GradientMesh { let rows = self.rows as usize; for rr in 0..=rows { let (l, rt) = (rr * w + c - 1, rr * w + c + 1); - let (pl, pr) = (self.points[l].p, self.points[rt].p); - self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0; - self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0; + let (pl, pr, m) = (self.points[l].p, self.points[rt].p, &self.points[rr * w + c]); + let hm = (m.p, m.handles[H_LEFT], m.handles[H_RIGHT]); + let (right, left) = merged_handles((pl, self.points[l].handles[H_RIGHT]), hm, (pr, self.points[rt].handles[H_LEFT])); + self.points[l].handles[H_RIGHT] = right; + self.points[rt].handles[H_LEFT] = left; } let mut k = 0; self.points.retain(|_| {