Merge pull request #4811 from mockydevs/fix/svg-mesh-flat-pieces

SVG export: a gradient mesh patch is cut no finer than its colour changes
This commit is contained in:
BFlat
2026-10-31 20:14:07 +07:00
committed by GitHub
7 changed files with 264 additions and 10 deletions
+16 -1
View File
@@ -63,7 +63,7 @@ pub use expand::{
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
#[cfg(test)]
@@ -204,6 +204,21 @@ mod tests {
assert!(huge.quads(1).is_empty());
}
/// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`]
/// gives way to: each patch's pieces are its own quads', so together they cover the mesh once.
#[test]
fn a_large_meshs_pieces_follow_the_quad_budget() {
let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
let area = |pts: &[Point]| {
let n = pts.len();
(0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::<f64>().abs() / 2.0
};
let pieces = m.pieces(32);
assert!(pieces.len() >= 3600, "{} pieces", pieces.len());
let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum();
assert!((total - 90_000.0).abs() < 90.0, "covered {total}");
}
/// A file's rows and columns can be anything: a point count too large to compute is no match
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
#[test]
+3 -2
View File
@@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool {
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined
/// where the colour hardly changes ([`GradientMesh::pieces`]).
pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> {
m.quads(n)
m.pieces(n)
.into_iter()
.map(|q| {
let mut ap = Appearance::default();
+117 -3
View File
@@ -35,6 +35,15 @@ pub struct MeshQuad {
pub corners: [(Color, f32); 4],
}
/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through
/// tessellation points, its colour and opacity.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPiece {
pub pts: Vec<Point>,
pub color: Color,
pub opacity: f32,
}
/// Create Gradient Mesh → Appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MeshAppearance {
@@ -358,13 +367,19 @@ impl GradientMesh {
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
/// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser
/// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least).
fn subdivision(&self, n: usize) -> usize {
n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1)
}
/// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre
/// and its corners.
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
if !self.is_valid() {
return vec![];
}
// Finer than the budget allows gives way: a quad per patch is the least.
let n = n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches()) as f64).sqrt() as usize).max(1);
let n = self.subdivision(n);
let mut out = Vec::with_capacity(self.patches() * n * n);
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
@@ -395,6 +410,105 @@ impl GradientMesh {
out
}
/// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG,
/// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads
/// joined into square blocks as large as keeps the colour across each block within about one
/// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps
/// all its quads. A piece's outline runs through its quads' outer corners, so it covers what
/// they covered, and it takes the colour at its centre.
pub fn pieces(&self, n: usize) -> Vec<MeshPiece> {
let n = self.subdivision(n);
let quads = self.quads(n);
let cols = (self.cols as usize).max(1);
let mut out = Vec::with_capacity(quads.len());
for (k, patch) in quads.chunks_exact(n * n).enumerate() {
let (r, c) = (k / cols, k % cols);
let side = self.block_side(r, c, n, patch);
if side == 1 {
out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity }));
continue;
}
// Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of.
let at = |i: usize, j: usize| -> Option<Point> {
let (qi, qj) = (i.min(n - 1), j.min(n - 1));
let q = patch.get(qj * n + qi)?;
q.pts
.get(match (i > qi, j > qj) {
(false, false) => 0,
(true, false) => 1,
(true, true) => 2,
(false, true) => 3,
})
.copied()
};
for j0 in (0..n).step_by(side) {
for i0 in (0..n).step_by(side) {
let (i1, j1) = (i0 + side, j0 + side);
// Around the block, in the quads' order: along its top, down its right side,
// back along its bottom and up its left side.
let ring = (i0..i1)
.map(|i| (i, j0))
.chain((j0..j1).map(|j| (i1, j)))
.chain((i0 + 1..=i1).rev().map(|i| (i, j1)))
.chain((j0 + 1..=j1).rev().map(|j| (i0, j)));
let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::<Option<Vec<Point>>>() else { continue };
let half = side as f64 / 2.0;
let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64);
out.push(MeshPiece { pts, color, opacity });
}
}
}
out
}
/// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into
/// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one
/// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline
/// of a block across the fold wouldn't fill it as its quads do.
fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize {
let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc));
let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else {
return 1;
};
// Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too.
let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. }));
let channels = |p: &MeshPoint| -> Vec<f32> {
let mut v = p.color.to_rgb().to_vec();
if inks && let Color::Cmyk { c, m, y, k } = p.color {
v.extend([c, m, y, k]);
}
v.push(p.opacity);
v
};
let [a, b, cc, d] = [p00, p01, p10, p11].map(channels);
// A bilinear patch changes across a block of side h (0..1 of the patch) by at most
// h × (du + dv) in each channel, du and dv its largest change along each parameter.
let mut steps = 0.0f32;
for k in 0..a.len() {
let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default();
let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs());
let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs());
steps = steps.max((du + dv) * 255.0);
}
if !steps.is_finite() {
return 1;
}
let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1);
if side > 1 {
// Every quad turning the same way: the patch doesn't fold, so a block's outline
// encloses just what its quads cover.
let turn = |q: &MeshQuad| {
let [a, b, c, d] = q.pts;
(b - a).cross(c - a) + (c - a).cross(d - a)
};
let first = quads.first().map(turn).unwrap_or_default();
if !quads.iter().all(|q| turn(q) * first > 0.0) {
return 1;
}
}
side
}
/// The mesh's outer boundary as a closed path.
pub fn outline(&self) -> PathData {
if !self.is_valid() {
+9 -3
View File
@@ -1020,10 +1020,15 @@ fn mesh_expand_to_flat_pieces() {
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap();
// The first patch shades from black to white; the second is one colour.
for (index, color) in [(0, "#000000"), (3, "#000000"), (1, "#ffffff"), (2, "#ffffff"), (4, "#ffffff"), (5, "#ffffff")] {
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": index, "color": color})).unwrap();
}
s.execute("object.mesh.expand", &json!({})).unwrap();
let g = node(&s, a);
assert!(matches!(g.kind, NodeKind::Group { .. }));
assert_eq!(g.children().unwrap().len(), 2 * 64);
// 8 × 8 pieces for the shaded patch, one for the flat one.
assert_eq!(g.children().unwrap().len(), 64 + 1);
assert!(g.children().unwrap().iter().all(|c| c.id != NodeId(0)));
}
@@ -1093,9 +1098,10 @@ fn live_objects_round_trip_through_native_format() {
fn svg_export_expands_live_objects() {
let s = live_doc();
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
// 4 blend objects + envelope content + 4 patches × 64 mesh pieces.
// 4 blend objects + envelope content + a piece for each of the 4 mesh patches (the mesh is
// white, which its highlight leaves white, so a patch is one piece rather than 64 alike).
let paths = vectorcraft_testkit::svg_shapes(&svg);
assert!(paths >= 4 + 1 + 256, "{paths}");
assert!(paths >= 4 + 1 + 4, "{paths}");
assert!(vectorcraft_svg::import(&svg).is_ok());
}
+1
View File
@@ -22,6 +22,7 @@ mod import_text;
mod import_units;
mod interactivity;
mod mask_options;
mod mesh_export;
mod non_scaling_stroke;
mod options;
mod prop_svg;
+116
View File
@@ -0,0 +1,116 @@
//! Gradient meshes in SVG export: SVG has no mesh paint, so a mesh is written as flat-coloured
//! pieces, each patch cut finely enough that neighbouring pieces differ by about one 8-bit
//! colour step at most, and no finer: a patch of one colour is one piece, not 64 alike.
use vectorcraft_color::Color;
use vectorcraft_doc::live::{GradientMesh, MeshAppearance};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{Image, channel_diff, render_artboard};
/// A 200 × 200 pt document with a `rows` × `cols` mesh over (20, 20)–(180, 180), each point
/// coloured by `color(row, col)`.
fn doc(rows: u32, cols: u32, color: impl Fn(usize, usize) -> Color) -> Document {
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0)
.expect("a mesh");
for r in 0..=rows as usize {
for c in 0..=cols as usize {
let i = m.idx(r, c);
m.points[i].color = color(r, c);
}
}
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap();
d
}
/// Filled shapes in the SVG.
fn pieces(svg: &str) -> usize {
["<polygon", "<rect", "<path"].iter().map(|e| svg.matches(e).count()).sum()
}
/// The exported SVG rendered by resvg on white at 1 px/pt.
fn resvg_render(svg: &str) -> Image {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
let mut pm = resvg::tiny_skia::Pixmap::new(200, 200).unwrap();
pm.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
Image { width: 200, height: 200, rgba: pm.data().to_vec() }
}
/// Compare the SVG as resvg draws it with the canvas, over the pixels `inside` the mesh (the
/// canvas grows a mesh's outline by half a pixel, so its edge is left out): at most `bad` of them
/// differ by more than `tol` in a channel.
fn assert_looks_alike(d: &Document, svg: &str, inside: impl Fn(f64, f64) -> bool, tol: u8, bad: f64) {
let (mine, canvas) = (resvg_render(svg), render_artboard(d));
let (mut n, mut off, mut worst) = (0usize, 0usize, 0u8);
for y in 0..200 {
for x in 0..200 {
if !inside(x as f64 + 0.5, y as f64 + 0.5) {
continue;
}
n += 1;
let c = channel_diff(mine.pixel(x, y), canvas.pixel(x, y));
worst = worst.max(c);
if c > tol {
off += 1;
}
}
}
assert!(n > 0 && (off as f64) <= bad * n as f64, "{off} of {n} pixels differ by more than {tol} (worst {worst})");
}
/// Inside the mesh square, two pixels in from its edge.
fn in_square(x: f64, y: f64) -> bool {
(22.0..178.0).contains(&x) && (22.0..178.0).contains(&y)
}
#[test]
fn a_patch_of_one_colour_is_one_piece() {
// 4 × 4 patches, every point the same colour: 16 pieces, not 16 × 64.
let d = doc(4, 4, |_, _| Color::rgb8(40, 120, 200));
let svg = export(&d, &ExportOptions::default());
assert!(pieces(&svg) <= 16, "{} pieces", pieces(&svg));
assert_looks_alike(&d, &svg, in_square, 2, 0.0);
}
#[test]
fn a_patch_is_cut_no_finer_than_its_colour_changes() {
// Each patch changes by one 8-bit step from left to right: one piece is close enough.
let d = doc(2, 2, |_, c| Color::rgb8(100 + c as u8, 100 + c as u8, 100 + c as u8));
let svg = export(&d, &ExportOptions::default());
assert!(pieces(&svg) <= 4, "{} pieces", pieces(&svg));
assert_looks_alike(&d, &svg, in_square, 2, 0.0);
}
#[test]
fn a_strong_gradient_is_still_cut_finely() {
// Black to white across one patch: as many pieces as before, so the steps stay small.
let d = doc(1, 1, |_, c| if c == 0 { Color::BLACK } else { Color::WHITE });
let svg = export(&d, &ExportOptions::default());
assert!(pieces(&svg) >= 64, "{} pieces", pieces(&svg));
// Flat pieces step where the canvas shades smoothly: within half a piece's step (255 / 16).
assert_looks_alike(&d, &svg, in_square, 17, 0.0);
}
#[test]
fn a_curved_patch_of_one_colour_keeps_its_shape() {
// A mesh over a circle: its patches' edges are curves, which one piece must still follow.
let mut m =
GradientMesh::for_path(&shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 180.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).expect("a mesh");
for p in &mut m.points {
p.color = Color::rgb8(200, 40, 60);
}
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap();
let svg = export(&d, &ExportOptions::default());
assert!(pieces(&svg) <= 4, "{} pieces", pieces(&svg));
// Two pixels in from the circle: the pieces' edges are straight between points 1/8 of a
// patch edge apart, as the quads' were.
assert_looks_alike(&d, &svg, |x, y| (x - 100.0).hypot(y - 100.0) < 78.0, 2, 0.0);
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF import: knockout and isolated groups keep their flags after a group drawn straight onto its parent) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (SVG export and Expand: a gradient mesh patch is cut no finer than its colour changes) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged |
| 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged |
| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged |
| 2026-10-31 | minor | PDF (and `.ai`) with Illustrator editing data saved with only some of its artboards: its pages are those artboards, while the editing data keeps them all, and the layers were compared with the first page on the first artboard, so a page of the second artboard came in as the PDF part, without its layers. With fewer pages than artboards, the first page is now taken to be the artboard of its size whose art draws most like it (up to 16 weighed), and the pages' type goes to that artboard and the ones after it. All artboards come in, as before. Test `import::tests_native::a_pdf_of_its_second_artboard_is_compared_on_that_artboard`; scores unchanged |