# Geometry standard: meshes, topology, budgets and LODs

Repo: **`jcom_v0bb_simulation_design_standards_geometry`**. Status: draft, 2026-10-20. Written by the Cobalt design advisor for dolphin4. This is advice; the owner rules on every item in [OPEN-DECISIONS.md](OPEN-DECISIONS.md).

Machine-readable form: [geometry-standard.json](geometry-standard.json). Tools: [SKILLS.md](SKILLS.md).

**One rule:** author once, high, in real metres, with every part named and pivoted. Every smaller size is then a scripted derivation. Nobody models a second size by hand unless a derivation is shown to fail.

## Why "geometry" and not "wireframes"

- A **wireframe** is a render mode, and it is the name of our inspection tool (the Wireframe Lab). It is not a standard.
- **Geometry** is the term both target engines use for this subject: Unreal's "Static Mesh geometry", "Nanite virtualized geometry" and "geometry LODs", and glTF's mesh/primitive geometry.
- The standard covers topology, budgets, LODs, compression and articulation. "Wireframes" would cover only the first, and only as seen on screen.
- The lab keeps its name, `svc_lab_wireframe`. It validates this standard.

## Siblings (linked, not repeated)

| Repo | Owns | Path |
|---|---|---|
| `design/standards_units` | axes, real dimensions, part names, joints and clips, delivery files. Its OD-01 to OD-16 are referenced here, not re-argued | [../design/standards_units/](../design/standards_units/) |
| `design_standards_lookdev` | materials, textures, texel density, colour, lighting, KTX2 | `../design_standards_lookdev/` (not written yet, 16:51) |
| `design_standards_blueprints` | orthographic sheets and SVG drawings | `../design/standards_blueprints/` per the units repo's link; not present yet |
| Wireframe Lab | the validator UI; rules in `rules/rules.v1.json` | `D:\z4yin\.inbox\dolphin4\lanes\wireframe-lab\wireframe-lab\` |

## Source key

- `GAME:` is the live game tree `M:\z4yin-work-overflow\hermes-share\codex-lanes\asset-split\sol-maps\work\` (read only).
- `LAB:` is the wireframe-lab lane (`D:\z4yin\.inbox\dolphin4\lanes\wireframe-lab\`).
- `V1:` is `LAB:wireframe-lab/rules/rules.v1.json`.
- **PROPOSED** marks a value with no project source. It needs owner approval before any tool fails a model on it.

---

## Glossary

| Term | Meaning here |
|---|---|
| Mesh / primitive | glTF mesh = one or more primitives; each primitive is one draw call with one material. |
| Draw call | One GPU submission. Roughly one per primitive per visible instance (unless instanced). |
| Triangle (tri) count | The budget unit. Quads and n-gons are counted after triangulation. |
| Vertex count | Counted *after* splitting at UV seams and hard edges. Often 1.5-3x the welded count. Matters more than tris on mobile. |
| Quad / n-gon | Four-sided / more-than-four-sided face. Quads for authoring; n-gons are not allowed on export. |
| Edge flow / edge loop | How edges run across a surface. Loops must ring a hinge so the part can rotate without shearing. |
| Manifold | Every edge has exactly two faces. Non-manifold: an edge with one face (open, a hole) or three or more faces (fins), or a vertex shared by two separate fans (bowtie). |
| Watertight / closed | Manifold with no boundary edges. GAME:`unit-structure.json` scores "closed". |
| Degenerate / zero-area face | A triangle whose area is about 0 (repeated or collinear vertices). V1:`geometry.degenerateAreaEpsilon`. |
| Flipped normal / winding | Face winding opposite to its neighbours, so it renders inside out. |
| Weld / merge by distance | Fuse coincident vertices. Weld inside a part; never weld across a joint. |
| Hard / soft edge (smoothing groups, sharp edges) | A hard edge splits vertex normals for a crisp crease; a soft edge shares them. |
| Custom (split) normals | Explicit per-corner normals stored in the mesh; how a baked or weighted-normal look survives export. |
| UV0 / UV1 (TEXCOORD_0 / _1) | First UV set: material textures. Second: lightmaps or AO, non-overlapping. |
| Texel density | Texture pixels per metre of surface. Owned by lookdev. |
| Pivot / origin | The local (0,0,0) of a node. For a moving part, it sits on the real rotation axis. |
| Node hierarchy | The glTF parent/child tree. Moving parts are child nodes of what carries them. |
| LOD (level of detail) | Lower-detail stand-ins at distance. LOD0 is the full game mesh. |
| HLOD / impostor / billboard | A merged far stand-in for many objects / a camera-facing card showing a pre-rendered view (octahedral impostors keep several views). |
| Screen size (Unreal) | Projected bounding-sphere diameter / screen height. LOD switches are expressed in it. |
| Geometric (simplification) error | Maximum distance the simplified surface moves from the original, in metres or screen pixels. |
| Decimation / simplification | Removing vertices by edge collapse under an error bound (meshoptimizer `simplify`, Blender Decimate "Collapse"). |
| Retopology | Rebuilding a clean low mesh over a high one (manual, QuadriFlow). |
| Baking | Projecting high-mesh detail (normals, AO, curvature) into the low mesh's textures through its UVs. Cage = the inflated shell that rays are cast from. |
| Quantisation | Storing positions, normals and UVs as 8- or 16-bit integers (`KHR_mesh_quantization`). |
| Meshopt / Draco | Two glTF geometry compressions: `EXT_meshopt_compression` / `KHR_draco_mesh_compression`. |
| Instancing | One draw call for many copies of the same geometry and material. |
| Nanite | Unreal 5 virtualized geometry: streams cluster LODs automatically from a dense master mesh. |
| Collision proxy / hull | A simple convex shape used for physics or picking instead of the render mesh. Unreal names them `UCX_<mesh>`. |
| Swept volume | The space a moving part occupies across its whole range of motion. |

---

## 1. Units and frame

| Item | Rule | Source |
|---|---|---|
| Unit | 1 unit = 1 metre. The game multiplies by `metreToWorld` 0.4 at load. Never pre-scale a model. | GAME:`data/rules/unit-part-contract.json:4`; GAME:`data/rules/world-scale.json` |
| Up | +Y | GAME:`unit-part-contract.json:6`; V1:`orientation.expectedUp` |
| Forward | **-Z** (decision OD-01 in the units repo, recommended) | GAME:`unit-part-contract.json:5`; GAME:`unit-structure.json:2` |
| Right (starboard) | +X. This follows from -Z forward and +Y up in glTF's right-handed frame. | glTF 2.0 spec §3.4 (right-handed, +Y up); units repo `axes.starboard` |
| Ground | Lowest wheel/track/foot on y = 0 (tolerance 0.05 m). Naval datum is OD-07 in the units repo. | GAME:`unit-structure.json` `tolerances.groundEpsilonM` |
| Unreal | Unreal is left-handed, Z-up, X-forward, 1 unit = 1 cm. Its glTF/Interchange importer converts on import; we never author in Unreal's frame. | Epic, "Coordinate system and spaces" and glTF importer docs (not fetched today) |

### Forward-axis conflict and a mirroring finding

- The game uses -Z forward. The owner's E-2D lab spec says "+Y is up, +Z points toward the nose, and +X is starboard" (LAB:`input/OWNER-SPEC.txt:13`).
- **New finding (checked 2026-10-20 in the GLB itself):** `reference_propeller_starboard` sits at x = +3.31 with the nose at +Z. glTF is right-handed. Facing +Z with +Y up, starboard must be at **-X**. So this export is either a **mirror image** or has its port and starboard labels swapped.
- A yaw offset cannot fix a mirror image: turning it 180 degrees to face -Z leaves the labels on the wrong sides.
- **Check before ingest:** read the NAVY lettering in the lab. Reversed text means the geometry is mirrored; fix with scale X -1 and recomputed winding. Readable text means the labels are swapped; rename the nodes.
- Recorded as OD-G1.

### Pivot / origin per class

| Class | Root origin | Moving-part pivots | Source |
|---|---|---|---|
| Ground vehicle | Centre of the track or wheel footprint in XZ, y = 0 at ground | turret: centre XZ, seat at its min Y. barrel: centred XY, breech at max Z. wheel: centred YZ on the axle | GAME:`unit-structure.json` `tolerances.pivots` |
| Infantry | Between the feet, y = 0 | skinned; joints per the units repo | GAME:`unit-structure.json` |
| Aircraft | Centre of the gear footprint in XZ, y = 0 with gear down | gear: centred XZ at the trunnion; rotor: hub centre XZ; prop: centred XY; fold: on the hinge line | GAME:`unit-structure.json` pivots; LAB spec |
| Ship / submarine | Midships on the centreline, y = 0 at the design waterline (OD-07) | turret, launcher, radar: centre XZ, seat min Y; prop: shaft line | GAME:`unit-structure.json` pivots |
| Building | Centre of the footprint, y = 0 at grade | doors on the hinge line (x) | GAME:`unit-structure.json` pivots; `bldg-defence-dims.json:4` |

- **Pivot tolerance:** within 20% of the part's size or 0.05 m, whichever is larger. GAME:`unit-structure.json` `pivotFraction`, `pivotMinM`.
- **Node hierarchy and names:** the units repo owns these, along with GAME:`unit-part-contract.json` `parts`, GAME:`unit-structure.json` `classes`, GAME:`unit-joints.json` `kinds` and `profiles`. The geometry rules that bear on names:
  - each named part is its own node and mesh;
  - LOD files reuse the **same node names and pivots** at every level, so the rig and states drive any LOD unchanged;
  - empties (`muzzle_01`, `fx_*`) carry no geometry.

## 2. Topology

| Check id | Rule | Severity | Source |
|---|---|---|---|
| `topo.authoringQuads` | Author hard-surface in quads with supporting loops at creases. Triangles are fine where they stay flat. | advice | PROPOSED (industry practice) |
| `topo.triangulateOnExport` | Export triangles only (glTF is triangles). Triangulate in Blender with a fixed method (Beauty), so every LOD triangulates the same way. | fail if quads or n-gons reach the export | glTF 2.0 primitive modes |
| `topo.noNgons` | No n-gons in the source at hinges, curved areas or anywhere else that will be decimated or deformed. | warn | PROPOSED |
| `topo.manifold` | No non-manifold edges inside a part. Open edges at most 2% of edges per part. | fail over the threshold | GAME:`unit-structure.json` `openEdgeFraction` 0.02 |
| `topo.degenerate` | Triangle area at least 1e-12 m²; warn at 1. | warn | V1:`geometry.degenerateAreaEpsilon`, `warnDegenerateCount` |
| `topo.finite` | All positions finite. | fail | V1:`geometry.requireFinitePositions` |
| `topo.consistentWinding` | No flipped faces; all normals point outward. | fail | PROPOSED |
| `topo.weldInsidePart` | Merge by distance at 0.1 mm inside each part. Never weld across parts. | fail if a moving part is welded to its parent | GAME:`unit-structure.json` "moving parts are not welded to the hull", `weldOverlapFraction` 0.08 |
| `topo.duplicateMesh` | The same mesh copied many times is stored once and instanced (wheels, rivets, VLS cells). | warn | V1:`geometry.duplicateMeshWarn` |
| `topo.hingeLoops` | Two or more edge loops ring every hinge and swivel. Hinge faces are separate shells with the clearance gap below. | advice / lab check | PROPOSED |

**Normals:**

- **Rule:** smooth by angle at 30 degrees, then mark hard edges where the real object has a crease. Export custom split normals. Every UV seam on a hard edge, and every hard edge on a UV seam, so the splits coincide and vertex count stays low.
- **Weighted normals:** allowed on LOD0 bevelled hard surfaces.
- **Bakes:** baked normal maps need tangents exported (MikkTSpace), matching the baker.
- **Source:** PROPOSED (industry practice).

**UVs:**

- **UV0:** non-overlapping on unique detail. Mirrored or overlapped islands are allowed only on symmetric parts that carry no baked text or markings.
- **UV1:** only when the target needs a lightmap or a separate AO. Unreal can generate one on import.
- **Seams:** on hard edges and hidden edges.
- **Padding:** at least 4 px at the 2048 bake.
- **Texel density and texture sizes:** owned by [lookdev](../design_standards_lookdev/). The texture size caps are 2048/1024/512 per LOD (GAME:`unit-structure.json` `textureMax`; units repo OD-03).
- **Source:** PROPOSED (industry practice).

## 3. Budgets

There are three numbers per class and per LOD, plus a master:

- **Master:** the source the LODs derive from. It never ships to the browser.
- **Hard cap:** the game's STRUCTURED gate. Over it, the pipeline decimates or fails.
- **Target:** what the runtime plans for and what LODs derive to.
- The Unreal column is the Nanite master itself.

### Triangle budgets (LOD0 / LOD1 / LOD2)

| Class | Master, sanity cap (PROPOSED) | Hard cap (game) | Desktop target | Phone target | Draw calls (game, not enforced) |
|---|---|---|---|---|---|
| infantry | 50,000 | 3,000 / 1,500 / 500 | 1,200 / 450 / 160 | 600 / 250 / 100 | 1 |
| tank | 250,000 | 8,000 / 3,000 / 1,000 | 3,500 / 1,200 / 400 | 1,500 / 600 / 240 | 2 |
| ifv_apc | 250,000 | 8,000 / 3,000 / 1,000 | 3,000 / 1,000 / 350 | 1,200 / 500 / 200 | 2 |
| wheeled | 150,000 | 8,000 / 3,000 / 1,000 | 2,200 / 800 / 280 | 1,000 / 400 / 160 | 2 |
| helicopter | 300,000 | 15,000 / 5,000 / 1,500 | 2,800 / 1,000 / 350 | 1,200 / 500 / 200 | 2 |
| fixed_wing | 300,000 | 15,000 / 5,000 / 1,500 | 2,500 / 900 / 300 | 1,100 / 450 / 180 | 2 |
| small_boat | 150,000 | 15,000 / 5,000 / 1,500 | 2,000 / 800 / 300 | 1,000 / 400 / 160 | 4 |
| escort | 1,000,000 | 15,000 / 5,000 / 1,500 | 8,000 / 3,000 / 1,000 | 3,500 / 1,400 / 500 | 4 |
| capital_ship | 2,000,000 | 15,000 / 5,000 / 1,500 | 15,000 / **5,500 / 1,800** | 6,000 / 2,500 / 900 | 4 |
| building | 300,000 | 6,000 / 2,500 / 800 | 5,000 / 2,000 / 750 | 2,200 / 900 / 350 | 3 |

Sources:

- Hard caps: GAME:`data/rules/unit-structure.json` `budgets.byBudgetClass`, also GAME:`scale-overrides.json` `desktopTriCaps`.
- Targets: GAME:`data/rules/quality-tiers.json` `classPolyBudgets`.
- Draw calls: GAME:`unit-structure.json` (`enforceDrawCalls: false`).

Notes:

- **Conflict:** the capital-ship desktop target exceeds its own hard cap at LOD1 and LOD2 (5,500 > 5,000; 1,800 > 1,500). This is OD-G3.
- **Materials:** at most 1 material plus the `houseTint` team-colour material per LOD (2 primitives) for vehicles, aircraft and infantry. Ships may use up to 4 (PROPOSED, matching the draw-call column). The E-2D lab export has **65 materials**: as a master that is acceptable; as a game LOD it needs a texture atlas and a material merge.
- **Vertices:** after seams, at most 1.5x the triangle target (PROPOSED). This is a warning only.
- **Bytes:** at most 2 MB per LOD0 GLB for units and 6 MB for ships, after meshopt and before textures (PROPOSED). V1 allows 16/32/64 MB, which is far too large for mobile.

### The 8k versus 25k conflict

| Source | Vehicle LOD0 | Where |
|---|---|---|
| Game STRUCTURED gate | 8,000 | GAME:`unit-structure.json` `byBudgetClass.vehicle` |
| Game desktop cap (decimate if over) | 8,000 | GAME:`scale-overrides.json` `desktopTriCaps.vehicle` |
| Game runtime target | 3,500 (tank) | GAME:`quality-tiers.json` `classPolyBudgets.tank` |
| Lab validator | 25,000 (`unit`), 40,000 (`escort`), 80,000 (`capital`) | V1:`budgets.profiles` |
| Origin of 25k | "not the 25000 architecture line"; no architecture file found in the game tree | GAME:`unit-structure.json` `budgets.description` |

These are not one number disagreeing. They measure **different stages**, and V1 has no stage concept. Options:

- **A. Lower V1 to the game caps.** One truth. But the lab could then no longer check masters: the E-2D at 105k would fail.
- **B. (Recommended) Give V1 stages.**
  - `master`: sanity caps from the table above.
  - `delivery`: reads the game caps by reference from `unit-structure.json`, never copied.
  - Delivery files are also *warned* at the quality-tier target.
  - The 25k `unit` profile is retired.
  - The lab shows which stage it judged.
- **C. Keep 25k as a "showcase LOD"** for the lab and close-up cinematics. This is a fourth tier to keep in sync, and nothing in the game uses it.
- **D. Raise the game caps to 25k.** Rejected: phone targets are 1,000-1,500 and the governor already sheds detail.

The units repo's OD-02 recommends treating 25k as the master cap. That fits option B, but 25k is too low for a bake or Nanite master: the E-2D alone is 105k. This is OD-G2.

### Unreal

- **Rigid parts (hulls, turrets, wings, rotodomes):** import the **master** as Nanite static meshes, one per articulated part, attached to the parent node or socket. Nanite supplies its own cluster LODs, so do not import a hand LOD chain for these.
- **When Nanite does not apply:**
  - skinned meshes (infantry and other deforming meshes). Nanite skinning exists from about 5.5 behind `r.Nanite.AllowSkinnedMeshes`, but third-party guides call it beta (sources below);
  - translucent parts (canopy glass, blurred rotor discs). Nanite does not render translucency. Masked materials are supported from 5.1 once enabled;
  - mobile Unreal targets.
- **Sources (third-party, 2026-10-20 web search; Epic's own page was not returned):** [uhiyama-lab Nanite notes](https://uhiyama-lab.com/en/notes/ue/nanite-high-poly-mesh-optimization/), [Medium, "When to use Nanite"](https://medium.com/@sarah.hyperdense/understanding-nanite-when-to-use-it-and-when-to-skip-it-b7fcfadc3058), [UE forum 5.8 translucent report](https://forums.unrealengine.com/t/bug-or-not-weird-static-mesh-result-in-ue5-8-after-applying-translucent-material-more-details/2733948).
- **For those meshes:** use the **desktop target chain** as Unreal LODs 0 to 2. Add a master-derived "LOD -1" only for cinematics.
- **Collision:** `UCX_` convex hulls (§6).
- **Status:** Unreal is not installed on SV3 or SV4 (`TOOLS-SV3-SV4.md`), so none of this is tested. PROPOSED, per Epic's Nanite and Static Mesh LOD documentation.

## 4. LOD chain

| Item | Rule | Source |
|---|---|---|
| Count | LOD0, LOD1, LOD2, then an impostor or counter. Same node names and pivots at every geometric LOD. | GAME:`unit-structure.json` `lodMarkers`; GAME:`quality-tiers.json` tiers |
| Delivery | One GLB per LOD, `<unit-id>.lod0.glb` and so on (units repo OD-13). The lab also accepts in-file `LOD<n>` nodes or `MSFT_lod`. | units repo OD-13; V1 `lod` |
| Ordering | Triangles strictly descend. LOD0 is required. | V1:`lod.requireLod0`, `requireDescendingTriangles` |
| Switch (current game) | Camera distance in metres: desktop LOD0 to 50, LOD1 to 120; phone 24 / 50. Per tier, `unitLodDistances` close/mid/far (ultra 50/120/400 down to potato 16/32/64). | GAME:`quality-tiers.json` `lodSwitch`, `tiers.*.unitLodDistances` |
| Error metric | **Screen-space geometric error at most 0.75 physical pixels** at the switch distance, at FOV 50 degrees and 1080 px, split between quantisation and simplification. Relative error 1% is a secondary ceiling. | GAME:`data/rules/client-performance.json` `lod` (`maximumScreenErrorPixels`, `maximumRelativeError`); implemented in GAME:`skirmish/src/mechanics/clientPerformance/meshLod.ts` |
| Screen size (Unreal, non-Nanite) | LOD1 at 0.5, LOD2 at 0.25, LOD3 (impostor) at 0.1 | PROPOSED |

**The error formula** (the same one the game uses):

- World size of one pixel at distance d: `p = d · 2·tan(fov/2) / H`.
- Allowed geometric error: `e = 0.75 · p`.
- Pass `e`, divided by the mesh's world scale, to meshoptimizer as an **absolute error**. meshoptimizer takes relative error, so divide by `meshopt_simplifyScale`.
- A LOD derives to the class **target** count *or* the error bound, whichever keeps more triangles. If that still exceeds the hard cap, the pipeline does "decimate-then-fail" (GAME:`quality-tiers.json` `deriveBudgets`).

Generation is automated, in this order:

1. **Per part.** Simplify each named part separately, never the merged unit, so joints and pivots survive. Preserve borders, seams and attributes. The game uses "exact full-attribute indexing preserves UV seams and hard normals" and a minimum of 12 triangles per part (GAME:`client-performance.json`).
2. **Tools.**
   - Blender headless `Decimate` (Collapse, triangulate, symmetry on X) for authored LODs.
   - `gltfpack -si <ratio>` or glTF-Transform `simplify` (both meshoptimizer) for scripted derivation.
   - The game already ships `meshoptimizer` 0.18.1 for runtime-derived far LODs (GAME:`skirmish/package.json`).
3. **Thin parts** (antennas, rotor blades, rails, masts) collapse badly. At LOD2, replace each with a card or a box; do not decimate it. This is the case where hand authoring is allowed.
4. **Far zoom (RTS).**
   - Beyond `impostorsAfter` units on screen, units become impostors.
   - Beyond `natoCountersAfter`, they become 2D counters (APP-6 style symbols, which are SVG masters; see §8).
   - Thresholds per tier: ultra impostors after 100 / counters after 140; potato 8 / 12 (GAME:`quality-tiers.json`). I did not check how the renderer implements impostors.
   - **Impostor bake (PROPOSED):** an 8-view or octahedral atlas from the master, 512 px, alpha-cut.
5. **Instancing.** Identical opaque rigid geometry and material is instanced from 2 copies (GAME:`client-performance.json` `instances.minimumCopies`). Team colour goes through the `houseTint` material, not separate geometry, so units of one class still instance. Moving parts each instance separately.

## 5. Minimisation and compression

"Minimising" covers four separate operations, applied in this order:

| Step | What | Rule | Source |
|---|---|---|---|
| 1. Merge | Join static, same-material meshes within a part. Atlas textures so one LOD has 1-2 materials. | Never merge across a joint. | PROPOSED |
| 2. Simplify | The LOD chain above. | §4 | GAME:`client-performance.json` |
| 3. Quantise | `KHR_mesh_quantization`: positions 14-16 bit, normals 8-10 bit, UVs 12-16 bit. | Quantisation error counts against the 0.75 px budget; the relative ceiling is 5%. | GAME:`client-performance.json` `maximumQuantizationRelativeError` 0.05 |
| 4. Compress | `EXT_meshopt_compression` via `gltfpack -cc` or glTF-Transform `meshopt`. | **meshopt, not Draco.** The game registers MeshoptDecoder only and has no Draco loader. Meshopt also decodes faster and keeps animation and morph data. | units repo OD-06; GAME:`skirmish/src/models.ts` |
| 5. Cull | Delete faces that are never visible: inside a turret ring, the hull bottom on ground units, interior bulkheads. | Only where no camera or cutaway reaches them; cutaway models keep them. | PROPOSED |

- **Order of validation:** validate *before* compressing. The lab's structural fallback fails meshopt- and Draco-compressed files (units repo OD-06). Compression is the last step of the pipeline.
- **Textures:** KTX2/Basis is a lookdev decision. The game has no KTX2 loader today.

### Collision proxies

- **The sim does not use meshes.** It collides circles: `fallbackRadiusM`, `circumradiusScale` 0.5 (GAME:`data/rules/collision.json`). Mesh proxies are therefore for picking, line of sight and Unreal physics.
- **three.js picking:** raycast the part bounding boxes or a `*_proxy` node, never the render mesh.
- **Unreal:** `UCX_<mesh>_NN` convex hulls, at most 8 per part, each at most 32 vertices. Wheels and turrets get one hull each.
- **Status:** PROPOSED (Unreal FBX/glTF collision naming convention).

## 6. Articulation geometry

- **Separate meshes at every joint.** A moving part is its own node and mesh and is never welded to its parent (GAME:`unit-structure.json`, `weldOverlapFraction` 0.08).
- **Pivots on the real axis.** The node origin sits on the hinge or rotation axis, and the joint axis is a local axis of that node: X for pitch, Y for yaw, Z for roll and extend (GAME:`unit-joints.json` `kinds`).
- **Rest pose:** clips start at rest (units repo OD-05).
- **Clearance:** each moving part keeps at least `clashPadM` 0.02 m from its neighbours across its whole range. Overlap growth stays at most `clashGrowM3` 0.0005 m³, checked in `sweepSteps` 12 samples over the limits (GAME:`unit-joints.json`).
- **Interfaces:**
  - **Turret ring:** a cylinder gap of at least 0.02 m.
  - **Hinge:** model the knuckle as concentric cylinders on the pivot axis, so the gap stays constant through the rotation.
  - **Fold lines:** cut on the true fold axis, not across a skewed edge.
- **Float:** no part may float more than `floatGapM` 0.15 m from its parent in any pose (GAME:`unit-joints.json`).
- **What the lab checks:**
  - swept-volume clash (part against every non-parent part, sampled across the limits);
  - pivot on axis;
  - unwelded joints;
  - the same node set at every LOD.
- **Actuator metadata:** in `extras.actuator` as V1 `actuators` (revolute, prismatic, continuous, fixed).
- **Every LOD keeps the parts.** A LOD may simplify a part but may not merge two parts that move relative to each other. LOD2 may drop *secondary* moving parts (hatches, flaps) by making them static children, and must still keep turret, barrel, wheels, rotors and gear.

## 7. Author high, derive low: the pipeline

### The owner's question: start from the biggest?

**Yes, with one correction: start from the most *detailed*, never from a bigger *scale*.**

- **Scale stays fixed.** Every size of an asset is the same real object at 1:1 metres. A smaller LOD has fewer triangles and fewer texture pixels; it is never a smaller model.
- **Why the detailed master comes first:**
  - **Reduction is a one-way, lossy function.** Simplification and downsampling can be scripted; adding detail cannot. Starting from the master makes every other size a mechanical output, and a change to the master regenerates all of them.
  - **The low mesh needs it.** The detail that makes a 3,500-triangle tank look good comes from normals and AO *baked from* a high mesh. Without a master there is nothing to bake.
  - **Unreal needs it.** Nanite consumes the dense master directly. One master feeds both engines: the browser gets the derivations and Unreal gets the original.
  - **Owner's principle.** "Everything is math and everything is designed to be real" supports this: one measured truth, with every other form computed from it.
- **What is not free:**
  - Below about 1,000 triangles, an automatic decimation of a complex silhouette (rotors, antennas, rails) degrades. Budget hand fixes at LOD2 and the impostor, for hero units only.
  - A master that is unstructured (one welded, unnamed, wrong-axis mesh) cannot be derived. It must be fixed first.

### What must be anchored

Anchor these once, in the master, and every smaller size is mechanical:

1. **Real-metre scale**, checked against GAME:`unit-dimensions.json` within 2%, with the 5% fail threshold (units repo OD-04).
2. **Axes:** +Y up, -Z forward, +X starboard, on the ground plane (§1).
3. **Part split, pivots and names** per the part contract. LODs inherit them by construction.
4. **UV0 layout made for the LOD0 game mesh**, so the master bakes into it. Lower LODs keep UV0 (simplification preserves attributes), so a **single texture set serves every LOD**: only the mip level changes. This is the single biggest saving.
5. **Baked maps** (normal in tangent space with MikkTSpace, AO, ID) baked once from master to LOD0.
6. **Hard-edge and seam marks**, which simplification is told to preserve.
7. **A metadata block** in `asset.extras`: unit id, `unit_dimensions`, master hash, standard version. A derived file names its master hash, so a stale LOD can be detected.

### Pipeline (SV3/SV4, headless; details in SKILLS.md)

```
master.blend  (Blender 4.2.3; or Cinema 4D / Substance Modeler -> .blend)
  |  bpy: validate (scale, axes, manifold, pivots, names)        -> lab rules
  |  bpy: build LOD0 game mesh (manual retopo or Decimate + UV0)  -> per part
  |  Blender bake (Cycles) or Substance Painter: master -> LOD0 normal/AO
  |  bpy: Decimate per part to LOD1, LOD2 targets; triangulate; export GLB
  |  gltf-validator + lab: validate each uncompressed GLB
  |  gltfpack / glTF-Transform: quantise + meshopt  (last step)
  |  impostor bake from master (Blender orthographic renders)
  v
<unit>.lod0.glb  <unit>.lod1.glb  <unit>.lod2.glb  <unit>.impostor.png
master -> Unreal (Nanite)  [not testable: Unreal not installed]
```

## 8. The SVG idea: vectors versus meshes

**The owner's instinct is right for vector content and does not carry over to meshes or textures.**

- **Vector content** (blueprint sheets, NATO/APP-6 counters, icons, decals, insignia, hull numbers, UI glyphs):
  - **one SVG is the master**;
  - set `viewBox` in real units: **1 user unit = 1 mm**, with `width`/`height` in `mm`;
  - an E-2D plan view is then `viewBox="0 0 24560 17600"` (span x length, from GAME:`unit-dimensions.json`);
  - put the origin at the model origin (shifted by `translate`) and include a scale bar;
  - it is "anchored" because it carries its true size, and every raster (decal at 2048, icon at 24 px, counter at 64 px) is a scripted render;
  - decals placed on a mesh use the same millimetre coordinates as the mesh's metres x 1000.
- **Meshes and textures are not vector.**
  - A mesh is already resolution-independent in *scale* (metres), but not in *detail*. Its "smaller sizes" are fewer triangles, which needs the simplify and bake path in §7.
  - A texture is a raster, so its smaller sizes are mips and downsamples of the largest bake. Lookdev owns those.
- **One link between the two:** blueprint SVGs drawn at 1:1 mm can be imported into Blender as curves at scale 0.001, to block out the master. The vector master then *anchors* the mesh master.

## 9. Acceptance (what the Wireframe Lab reports per file)

| Check | Applies to |
|---|---|
| Stage | master or delivery |
| Class | from the classifier, with the stage's budget |
| Triangles, vertices, primitives, materials | against cap and target |
| Scale and axes | including the handedness check (OD-G1) |
| Manifold, degenerate, flipped, finite | |
| Pivots on axis; moving parts unwelded | |
| Swept-volume clash | at `clashPadM` |
| LOD node-set equality; descending triangles | |
| glTF validity | Khronos validator |
| Compression | none at validation; meshopt only after |

Rule ids are in [geometry-standard.json](geometry-standard.json) `checks`, aligned with V1 keys where V1 already has the rule.
