# LOD method comparison (issue #126)

Offline comparison of two ways to reduce triangle count for the
[#76](https://github.com/1n01raymond/naru/issues/76) LOD slice:

1. **OCCT retessellation** — the STEP adapter re-run at explicitly declared
   linear/angular tolerances (`--linear-tolerance`, `--angular-tolerance`).
2. **meshoptimizer 1.2.0 simplification** (`MeshoptSimplifier.simplify` /
   `simplifyWithAttributes`, normal weight 0.5) of one adapter mesh
   (0.15 mm / 0.15 rad), in three constraint modes: per-face with locked
   face boundaries, whole shape with locked CAD boundary vertices, whole shape
   unlocked. Simplification only drops triangles and re-references original
   vertices, so output precision and encoding are unchanged.

Record: [`lod-method-comparison.json`](lod-method-comparison.json)
(`naru.lod-method-comparison.1`, mode
`offline-tolerance-and-simplification-comparison`). Re-record with
`pnpm lod:method:evidence`, validate with `pnpm lod:method:check`.

## Fixed inputs

- Source: [`fixtures/step/lod-corpus.step`](../../../fixtures/step/lod-corpus.step),
  114,233 B, sha256 `02c78ff73b23…`, units mm, generated by
  `fixtures/step/generators/generate_lod_corpus.py` (Apache-2.0). Four parts:
  `curved-shell` (revolved shell with a cylindrical boss), `thin-plate-holes`
  (2 mm plate, through-holes), `planar-control` (box, 28 triangles at every
  tolerance), `fillet-bracket` (L-bracket with fillets).
- Reference mesh: adapter at 0.02 mm / 0.05 rad, 30,313 / 5,072 / 28 / 2,296
  triangles, 3,358,668 encoded bytes.
- Toolchain: CadQuery 2.8.0 / OCP 7.9.3.1, compiler 0.0.0, meshoptimizer
  1.2.0, Node v22.14.0; Windows x64, Ryzen 7 9800X3D.
- Every arm is built twice and must be byte-identical (`deterministic: true`).

## Predeclared thresholds

Declared in the recorder before any reduction target was chosen; the validator
pins them. Per arm with tolerance or target error `t` (mm):

| Check | Limit | Source of truth |
|---|---|---|
| `sampled-two-sided-p95` | `t` | 2,000 seeded samples each way, mesh to mesh (**sampled**) |
| `sampled-two-sided-max` | `2t` | same samples |
| `analytic-max` | `t` | distance to the described analytic faces (`describe_analytic_faces.py`, planes / cylinders / spheres) where a face has one |
| `edge-boundary-delta` | 0 | explicit-edge segment count against the reference |
| `edge-alignment-max` | `t` | reduced boundary vertices to the reference edge polyline |
| `section-max` | `t` | plane-section polyline distance, 8 samples per segment |
| `silhouette-ratio-max` | 0.005 | 256 px silhouette mask disagreement over six axis views |
| `hole-count-delta` | 0 | genus from the Euler characteristic |
| `identity-conflicts` | 0 | every reduced triangle maps to exactly one source face id |

A meshoptimizer error is a QEM value, not a certified CAD tolerance; the checks
above are what qualify an arm, never the library's own error.

## Results

Triangles per part (`✗` = fails at least one predeclared check); encoded bytes
are corpus-wide.

| Arm | Encoded B | curved-shell | thin-plate-holes | planar-control | fillet-bracket |
|---|---|---|---|---|---|
| reference 0.02/0.05 | 3,358,668 | 30,313 | 5,072 | 28 | 2,296 |
| occt-0.05-0.1 | 1,051,758 | 7,775 | 2,552 | 28 | 1,164 |
| occt-0.15-0.15 | 577,283 | 3,623 | 1,712 | 28 | 784 |
| occt-0.5-0.3 | 235,931 | 971 ✗ silhouette | 872 | 28 | 408 |
| occt-1-0.5 | 144,898 | 405 ✗ silhouette | 552 | 28 | 264 |
| meshopt-per-face-locked-0.5 | 315,289 | 646 ✗ | 1,712 | 28 | 784 |
| meshopt-per-face-locked-1 | 301,208 | 486 ✗ | 1,712 | 28 | 784 |
| meshopt-whole-shape-locked-0.5 | 316,345 | 658 ✗ | 1,712 | 28 | 784 |
| meshopt-whole-shape-locked-1 | 303,496 | 512 ✗ | 1,712 | 28 | 784 |
| meshopt-whole-shape-unlocked-0.5 | 153,169 | 552 ✗ | 514 | 28 | 234 |
| meshopt-whole-shape-unlocked-1 | 110,918 | 348 ✗ | 310 | 28 | 162 |

32 of 40 cells pass. Decoded and GPU residency cost per part is in each arm's
`residency` (for example curved-shell 305,412 B at occt-0.15-0.15 versus
30,312 B at meshopt-whole-shape-unlocked-1). Adapter time per arm is
1.7–2.0 s; compile time 2–8 ms; simplification is sub-millisecond.

## Findings, stated honestly

- **Curved parts need retessellation.** Every meshoptimizer arm fails
  `curved-shell`: the sampled p95 lands just above the target (0.511 mm at
  0.5, 1.019 mm at 1), the analytic maximum runs 1.6–1.7× the target, the
  section and silhouette checks fail with it, and `edge-boundary-delta` is 1
  because the adapter's seam edge on the revolved face is a single segment
  that simplification cannot preserve. OCCT at 0.05/0.1 and 0.15/0.15 passes
  every check; 0.5/0.3 and 1/0.5 fail only the silhouette ratio (0.0096 and
  0.0190 against 0.005), so the declared tolerance is the lever, and a
  coarser tolerance than 0.15 mm is not shape-preserving on this part.
- **Locked-boundary simplification cannot reduce enough.** With face or CAD
  boundary vertices locked, `thin-plate-holes` and `fillet-bracket` keep their
  input triangle counts (1,712 / 784) at every error: the boundary vertices
  are most of the mesh.
- **Unlocked simplification passes only on planar-dominant parts.** The
  unlocked arm at 1 mm reduces `thin-plate-holes` to 310 and `fillet-bracket`
  to 162 triangles with 0 identity conflicts, 0 hole-count delta, and the
  boundary edge stream intact, because their boundary edges are straight or
  circular and the adapter emits them as explicit edge segments independent of
  the reduced surface.
- **`planar-control` is a tie.** All ten arms leave the box at 28 triangles;
  the record marks `tie: true` and lists every tied label rather than
  pretending one method won.
- **Source identity survives every arm.** `identity-conflicts` is 0 in all
  40 cells: the reducer carries `faceSourceIds` per triangle, so picking and
  section behaviour resolve to source faces regardless of the representation's
  indices.
- **Measurement fix, not a threshold change.** During the slice the section
  metric treated a vertex lying exactly on the plane as a crossing on both
  sides and reported phantom segments; the tie-break now counts an on-plane
  vertex as the positive side. Thresholds were not touched.

## Selection

| Part | Selected arm | Triangles |
|---|---|---|
| curved-shell | occt-0.15-0.15 | 3,623 |
| thin-plate-holes | meshopt-whole-shape-unlocked-1 | 310 |
| planar-control | tie across all ten arms | 28 |
| fillet-bracket | meshopt-whole-shape-unlocked-1 | 162 |

Conclusion for #76: the narrow supported topology class for meshoptimizer
simplification is planar-dominant parts whose CAD boundary edges are carried
as explicit edge segments; curved parts take OCCT retessellation at a declared
tolerance no coarser than 0.15 mm / 0.15 rad on this corpus, and any part that
fails a predeclared check keeps the full-detail representation. None of this
generalizes to sixty5 or to IFC-derived meshes, which were not measured.

## Limits

- Four parts, one host. Digests are host-local; the validator pins them and
  they must not be retargeted to make a re-record pass.
- Distances are mesh-to-mesh samples except `analytic-max`, which covers only
  planes, cylinders, and spheres the describer recognised.
- No browser or screen-space measurement: the record settles method and
  topology class, not a screen-error policy.
