Merge pull request #4958 from dotCooCoo/fix/issue-3187

Part of #3187: a gradient mesh made from an ellipse keeps its shape
This commit is contained in:
BFlat
2026-11-01 02:23:58 +07:00
committed by GitHub
2 changed files with 177 additions and 8 deletions
+54
View File
@@ -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), 0.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), 0.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 =
+123 -8
View File
@@ -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<Point> = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect();
let mut ts: Vec<f64> = (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<f64> {
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<Self> {
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(|_| {