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.
This commit is contained in:
dotCooCoo
2026-11-01 01:59:57 +07:00
committed by bflatastic
parent dfd07552c8
commit 23da2fe461
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)).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 =
+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(|_| {