Merge PR #3035: fix(render): mesh triangles that share an edge leave no seam

vendor/hayro-interpret/src/encode.rs: both sides; a pixel sampled by either test (corner held, or centre covered) widens the mesh extent from #3296
vendor/README.md: main's row plus the PR's patch 36 and its test
This commit is contained in:
aiongg
2026-10-22 01:47:35 +00:00
4 changed files with 156 additions and 4 deletions
+120
View File
@@ -5347,6 +5347,126 @@ trailer << /Root 1 0 R >>
assert!(p != [255, 255, 255, 255] && p[3] == 255, "the triangles are drawn: {p:?}");
}
/// Two triangles of a mesh that share an edge cover it together. The sampler tested each
/// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share
/// was left white wherever it ran through the samples, and so was a pixel row or column
/// along the mesh's own sides. Vendored hayro-interpret patch 36.
#[test]
fn mesh_triangles_sharing_an_edge_leave_no_seam() {
// A red square from 10 to 40 on a 50 × 50 page, cut along its diagonal; in device space
// the diagonal runs through pixel centres and corners alike.
let vertex = |flag: u8, x: u8, y: u8| [flag, x, y, 255, 0, 0];
let (lo, hi) = (10, 40);
let mut triangles = Vec::new();
for [a, b, c] in [[(lo, lo), (hi, lo), (hi, hi)], [(lo, lo), (hi, hi), (lo, hi)]] {
for (x, y) in [a, b, c] {
triangles.extend(vertex(0, x, y));
}
}
// Each triangle as its own three vertices, and the second one as a strip (flag 1).
let mut strip = Vec::new();
for (flag, (x, y)) in [(0, (lo, lo)), (0, (hi, lo)), (0, (hi, hi)), (2, (lo, hi))] {
strip.extend(vertex(flag, x, y));
}
for (label, data) in [("separate triangles", triangles), ("a strip", strip)] {
let content = b"/Sh0 sh";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes());
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{label}: {:?}", page.error);
assert_eq!((page.width, page.height), (50, 50));
let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new());
for y in 0..50_usize {
for x in 0..50_usize {
let p = &page.rgba[(y * 50 + x) * 4..][..4];
let inside = (10..40).contains(&x) && (10..40).contains(&y);
if inside && p != [255, 0, 0, 255] {
white_inside.push((x, y));
} else if !inside && p != [255, 255, 255, 255] {
red_outside.push((x, y));
}
}
}
assert!(white_inside.is_empty(), "{label}: unshaded pixels inside the square: {white_inside:?}");
assert!(red_outside.is_empty(), "{label}: shaded pixels outside the square: {red_outside:?}");
}
// One triangle alone, the square's upper left half: a pixel whose corner lies on its
// diagonal is taken by its centre, so one wholly across the diagonal (x + y ≥ 50 in
// device space) is not shaded, and one wholly inside is.
let mut data = Vec::new();
for (x, y) in [(lo, lo), (hi, hi), (lo, hi)] {
data.extend(vertex(0, x, y));
}
let content = b"/Sh0 sh";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes());
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
let (mut spilled, mut missing) = (Vec::new(), Vec::new());
for y in 10..40_usize {
for x in 10..40_usize {
let p = &page.rgba[(y * 50 + x) * 4..][..4];
if x + y >= 50 && p == [255, 0, 0, 255] {
spilled.push((x, y));
} else if x + y + 2 <= 50 && p != [255, 0, 0, 255] {
missing.push((x, y));
}
}
}
assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}");
assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}");
}
/// A pixel whose corner is inside one triangle and whose centre is inside its neighbour
/// takes the colour at its corner, whichever triangle comes first (patch 36): the
/// centre-coverage pass only fills pixels no triangle has given a colour yet. Here the
/// shared diagonal lies at x + y = 50.25 in device space, so pixel (20, 30) has its corner
/// in the red triangle and its centre in the blue one.
#[test]
fn a_shared_edge_pixel_keeps_the_colour_at_its_corner() {
let vertex = |x: u8, y: u8, rgb: [u8; 3]| [0, x, y, rgb[0], rgb[1], rgb[2]];
let (lo, hi) = (10, 40);
let (red, blue) = ([255, 0, 0], [0, 0, 255]);
// In PDF space the red triangle is the square's upper left half, the blue one the lower
// right; `cm` moves both down a quarter point.
let red_triangle = [(lo, lo, red), (hi, hi, red), (lo, hi, red)];
let blue_triangle = [(lo, lo, blue), (hi, lo, blue), (hi, hi, blue)];
for (label, order) in [("red first", [red_triangle, blue_triangle]), ("blue first", [blue_triangle, red_triangle])] {
let mut data = Vec::new();
for triangle in order {
for (x, y, rgb) in triangle {
data.extend(vertex(x, y, rgb));
}
}
let content = b"1 0 0 1 0 -0.25 cm /Sh0 sh";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes());
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{label}: {:?}", page.error);
let pixel = |x: usize, y: usize| page.rgba[(y * 50 + x) * 4..][..4].to_vec();
assert_eq!(pixel(20, 30), [255, 0, 0, 255], "{label}: the corner of pixel (20, 30) is in the red triangle");
// Controls: wholly inside one triangle or the other.
assert_eq!(pixel(15, 20), [255, 0, 0, 255], "{label}");
assert_eq!(pixel(35, 30), [0, 0, 255, 255], "{label}");
}
}
/// Review of the mesh-shading patch: every patch was cut into 722 triangles before anything
/// limited them (about 150 KB per patch), and a lattice mesh with `/VerticesPerRow 0` pushed
/// empty rows for ever. Vendored hayro-interpret patch: patches are triangulated one at a
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -3
View File
@@ -353,9 +353,23 @@ impl TriangleSampler {
return false;
}
self.visits_left -= 1;
let point = Point::new(x as f64, y as f64);
if t.contains_point(point) {
self.map.insert((x, y), t.interpolate(point));
let corner = Point::new(x as f64, y as f64);
let sampled = if t.contains_point(corner) {
self.map.insert((x, y), t.interpolate(corner));
true
} else if t.covers(Point::new(x as f64 + 0.5, y as f64 + 0.5)) {
// PdfCraft patch (36): a pixel whose corner no triangle holds strictly (it
// lies on an edge two triangles share, or on the mesh's side) still belongs
// to the triangle that covers its centre, edges included. Testing only the
// corner left a white seam along every shared edge. Such a pixel takes the
// colour at its corner, as every other pixel does (where MuPDF's colours
// agree), unless a triangle that holds the corner gives it one.
self.map.entry((x, y)).or_insert_with(|| t.interpolate(corner));
true
} else {
false
};
if sampled {
self.touched = Some(match self.touched {
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
None => (x, y, x, y),
+18
View File
@@ -387,6 +387,24 @@ impl Triangle {
self.kurbo_tri.winding(pos) != 0
}
/// PdfCraft patch (36): whether the point is inside the triangle or on one of its edges.
/// Two triangles of a mesh share an edge; a sample on it belongs to both (their colours
/// agree there), where `contains_point` can give it to neither and leave a seam. A
/// degenerate triangle covers nothing.
pub fn covers(&self, pos: Point) -> bool {
// Relative to the barycentric coordinates' scale (1), well above f64 rounding.
const EDGE: f64 = 1e-9;
let (a, b, c) = (self.p0.point, self.p1.point, self.p2.point);
let (v0, v1, v2) = (b - a, c - a, pos - a);
let denom = self.d00 * self.d11 - self.d01 * self.d01;
let (d20, d21) = (v2.dot(v0), v2.dot(v1));
let v = (self.d11 * d20 - self.d01 * d21) / denom;
let w = (self.d00 * d21 - self.d01 * d20) / denom;
// A degenerate triangle divides by zero: NaN fails every comparison, and infinite
// coordinates can't sum to at most one.
v >= -EDGE && w >= -EDGE && v + w <= 1.0 + EDGE
}
/// Return the bounding box of the triangle.
pub fn bounding_box(&self) -> kurbo::Rect {
self.kurbo_tri.bounding_box()