nigig-org/REVIEWS/adr/0029-pdf-mesh-shadings.md
andodeki 728fbc3ad0
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
fix(pdf): mesh shadings — three bugs in code that had no fixture
ADR 0028 shipped types 4-7 and said honestly that they were unproven: the
uncovered lines of `shading.rs` were exactly `parse_mesh`, "the position
`image.rs` was in before ADR 0016 found the JPEG decoder was a stub".

Writing the fixtures found three real bugs.

- Type 5 has no per-vertex flag; `/VerticesPerRow` delimits it. Reading 8
  phantom bits shifted every vertex after the first, decoding plausible
  coordinates that were entirely wrong.
- Types 6 and 7 are patches: 12 or 16 control points carrying no colour,
  then four corner colours. The old loop read a colour per point, consumed
  three times too many components, ran off the stream, and the
  None-on-truncation path swallowed it as "the mesh ended".
- A flag-0 triangle is three vertices whose second and third flags are
  ignored (§8.7.4.5.5). Acting on them cleared the strip every time and
  produced no triangles at all. Caught in new code, before it shipped.

And one omission: `color_at_point` returned None for a mesh, so a mesh that
parsed perfectly still painted nothing — indistinguishable from one that
failed. `MeshTriangle::color_at` now interpolates the corner colours by
barycentric coordinates, None outside, because black is a colour a mesh can
legitimately produce.

Shared-edge patches (flags 1-3) inherit the previous patch's edge rather
than being read as fresh patches, which desynchronised the rest of the
stream.

Five corpus fixtures, generated from named coordinates and colours so every
expected value in the tests is one the generator wrote deliberately. Eight
tests, five mutations, all killed. Coons flattening is still an
approximation and still reports `is_approximate`.

ADR 0029.
2026-08-18 19:10:49 +00:00

5.9 KiB
Raw Permalink Blame History

ADR 0029: mesh shadings — the code was written, and it was wrong

  • Status: Accepted
  • Date: 2026-08-18
  • Review item: NIGIG_PDF_FEATURE_PARITY_PLAN.md §1 Phase 7, "Shading: … + mesh (free-form/lattice/Coons)"
  • Supersedes: the mesh half of ADR 0028
  • Related: ADR 0016 (the JPEG decoder that was a stub), ADR 0028 (shadings), ADR 0021 (enumerate criteria from the plan)

Context

ADR 0028 shipped parse_mesh and triangulate for shading types 47, and said so honestly: "mesh types 47 written but unproven — shading.rs is at 68% and the uncovered part is exactly parse_mesh." It named the risk in its own words:

the position image.rs was in before ADR 0016 found the JPEG decoder was a stub.

That was the right diagnosis. Writing the fixtures found three real bugs in code that compiled, had no warnings, and would have been reported as a completed feature.

Bug 1 — type 5 has no flag field

parse_mesh read bits_per_flag bits at the start of every record for all four types. A lattice-form (type 5) stream carries no flags at all; /VerticesPerRow is what delimits it. Reading 8 phantom bits shifted every subsequent vertex by one byte, so the coordinates decoded to arbitrary points inside the decode range — plausible numbers, in the right ballpark, entirely wrong. a_lattice_mesh_has_no_per_vertex_flag is that test.

Bug 2 — patches carry four colours, not one per control point

Types 6 and 7 are patches, not vertices: one flag, then 12 (Coons) or 16 (tensor) control points carrying no colour of their own, then four corner colours. The old loop read a colour after every point — 12 colours where the stream had 4 — consuming three times too many components and running off the end of the stream, which the None-on-truncation path then silently swallowed as "the mesh ended".

The old code also treated a control point as a vertex and then took indices 0, 3, 6 and 9 of a 12-vertex window, so even when it did not run short the corners were read from the wrong offsets.

Bug 3 — flag 0 cleared the strip on every vertex

Found by the fixture, in new code, before it shipped: a flag-0 triangle is three vertices and the second and third also carry flag 0, their flags ignored per §8.7.4.5.5. Acting on them cleared the strip each time and produced no triangles at all.

Also missing outright: a parsed mesh had no way to produce a colour. color_at_point returned None for ShadingGeometry::Mesh, so a mesh that parsed perfectly still painted nothing — indistinguishable, from the caller's side, from one that failed.

Decision

One reader per type, not one loop with conditionals

parse_mesh now branches on the shading type up front. The four encodings differ in what a record is — a flagged padded vertex, an unflagged unpadded vertex, or a patch — and the previous attempt to express that as a single loop with two if shading_type == adjustments is precisely how bugs 1 and 2 got in.

Shared edges are honoured, not treated as new patches

Types 6 and 7 with flag 13 share an edge and two colours with the previous patch and therefore encode four fewer points and two fewer colours. Treating a continuation as a fresh patch does not merely draw one patch wrong: it desynchronises the rest of the stream. previous carries the corners forward and the flag selects which edge (table 85).

Gouraud interpolation by barycentric coordinates

MeshTriangle::color_at returns the interpolated corner colours, or None outside the triangle — barycentric coordinates are both the inside test and the weights, so computing them any other way does the same arithmetic twice. Shading::color_at_point now walks the triangles, later ones winning, which matches the painting order of the vertex stream.

None outside is the ADR 0028 rule again: black is a colour a mesh can legitimately produce, so "no colour here" cannot be spelled black.

Coons flattening is still an approximation, and still says so

Two triangles across the four corners loses the curvature of the patch edges. is_approximate reports it. This ADR does not fix that, and does not claim to: a_coons_patch_reads_four_corner_colours_not_one_per_control_point asserts the flag is set.

Consequences

  • Mesh shadings decode correctly and can be sampled. shading.rs coverage rises from 68%; the uncovered region was exactly the code this ADR fixed.
  • Coons and tensor patches are drawn as flat quadrilaterals. A patch with strongly curved edges will show straight ones. Recorded, not hidden.
  • The corpus gains five fixtures, generated by generate.py from named coordinates and colours, so every expected value in the tests is a number the generator wrote deliberately rather than one copied out of the output.

Merge criteria

Enumerated from the plan bullet first, per ADR 0021.

Criterion State
Free-form (type 4) parses a real stream mesh_free_form.pdf, vertices and strip flag asserted
Lattice-form (type 5) parses a real stream mesh_lattice.pdf; the no-flag bug is the test
Coons (type 6) parses a real stream mesh_coons.pdf, four corner colours asserted
Tensor (type 7) parses a real stream mesh_tensor.pdf, 16 control points asserted
A mesh with /Function decodes mesh_function.pdf, one parametric value per vertex
A mesh produces colour at a point MeshTriangle::color_at, Gouraud-interpolated
Outside a triangle is None, not black asserted in a_free_form_mesh_interpolates_across_a_triangle
Truncated streams stop rather than inventing vertices asserted; BitReader::read returns None
Curved patch edges rendered exactly deferred — flattened to two triangles, reported by is_approximate
Mutation-checked 5 mutations, all killed (type-5 flag, tensor stride, per-point colour, inside test, strip flag)