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.
120 lines
5.9 KiB
Markdown
120 lines
5.9 KiB
Markdown
# 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 4–7, and
|
||
said so honestly: *"mesh types 4–7 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 1–3 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) |
|