Gunnar

nordisk/facett

.nornir/flight-facet-and-world-frame-design.md

# FLIGHT viewer — the `Facet` pane, pedestrian WALK, and the ONE world frame

`nornir:class=design`

The FLIGHT half of Rickard's two-viewer partition (see `osm-3d-gaps.md`) finally
has a host-facing pane, a street-level walk, and — the load-bearing part — a
single, unambiguous world frame. This doc names only the chosen design.

---

## 1. `FlightFacet` — the pane that was missing

`facett-map3d-flight` shipped a complete engine (floating origin · atmospheric
scattering · 6DOF rig · CDLOD terrain · day cycle · tile streaming) and **no
`Facet`**. `osm-3d-gaps.md` row C:69 recorded it as PARTIAL for that reason. The
consequence was not cosmetic: with no `Facet` there was no way for a `FacetDeck`
to mount it, no way for a robot script to drive it, and no `state_json`/pixel
oracle over it — so the engine's correctness was only ever checked by its own
unit tests, in its own vocabulary.

`facett-map3d-flight/src/facet.rs` is that pane. The design:

- **It owns no renderer.** The pane holds FLIGHT-side state (the `FlightCamera`
  rig, the `DayCycle` sun, the locomotion lane) and drives
  `facett_map3d::Map3D` as its **shared render core**. Extrusion, the watertight
  roof shell, ground-plane road ribbons and the CPU/wgpu painter are fixed once
  in `facett-map3d` and both viewers benefit — the partition doc's "shared
  render-core" rule, honoured literally (L5).
- **It duplicates nothing from the NAVIGATOR side.** No turn-by-turn, no route
  overlay, no ETA/next-turn HUD. Those belong to the navigator and stay there.
- **The camera bridge is a translation, not a second camera model.** The rig's
  yaw/pitch are pushed into the render core through its existing *Garmin
  vocabulary* (`set_bearing_deg` / `set_tilt_deg`), which is already the single
  source of truth both viewers read.
- **Gated on the `egui` feature.** A `Facet` is by definition an egui pane; the
  engine core stays egui-free and headless-testable.

`state_json` publishes the whole pose — `lon`/`lat`/`altitude_m`/`ground_m`/
`eye_height_m`, `yaw_deg`/`pitch_deg`/`roll_deg`, `locomotion`, `hour`,
`sun_elevation_deg`, `cloudiness` — with the render core's own state folded in
under `map`, so the pixel oracle (`RenderReport.vertices` + non-zero geometry)
applies unchanged and the state oracle is exact.

## 2. `Locomotion::Walk` — the pedestrian street-level mode

`osm-3d-gaps.md` row C:72 was the last open FLIGHT-owned camera gap: *"no
pedestrian eye-height locomotion yet — the tour drive is a car-nav chase, not a
first-person walk"*, explicitly a FLIGHT feature and explicitly **not** a
navigator one.

`Locomotion` is a superset of the engine's `FlyMode` — `Fly` / `Stick` / `Orbit`
map onto it one-to-one, and `Walk` rides the free-fly integrator with three
pedestrian constraints layered on top. Each constraint is a separate contract
with its own RED-when-broken test:

| Constraint | Value | Why it is a real contract |
|---|---|---|
| Eye pinned above ground | `WALK_EYE_HEIGHT_M = 1.70` | Entering `Walk` snaps the eye; there is no frame in which a "pedestrian" is still at cruise altitude. `set_ground_m` (the host's DEM sample) moves the walker with the terrain. |
| Horizontal-only travel | vertical input dropped, forward flattened to the ground plane | Without it, looking up and pressing forward lifts you off the street — `Walk` would just be slow flying. `walking_forward_while_looking_up_does_not_take_off` walks 100 s at the neck limit and asserts zero altitude gain. |
| Walking pace | `WALK_SPEED_MPS = 1.40` (≈ 5 km/h) | A jet-speed request must still cover ≤ 1.40 m in a second. |
| Neck clamp | `WALK_PITCH_LIMIT = 70°` | A human neck does not hinge to the vertical; unclamping it is what turns a walk back into a flight. |

In `Walk` the render core's dolly is pulled to the closest legal radius, so the
walker stands **inside** the extruded city rather than above it.

## 3. THE WORLD FRAME — one frame, one swap (the bug this round found)

The sharp finding. `WorldPos` defines, and its own methods enforce, the frame

```
WorldPos = { x: Mercator easting, y: Mercator northing, z: altitude }
```

`from_lonlat` writes it, `to_lonlat` reads `y` as the northing, `ground_distance`
measures `(x, y)`, and `terrain.rs` states the render swap explicitly at the
boundary: `positions.push([r[0], r[2], r[1]]) // → Y-up render frame
[easting, elev, northing]`.

**Three places disagreed with that**, all in the same direction — they treated
slot 1 as *altitude*:

1. `camera_fly::forward()` returned `[cos·cp, sin(pitch), sin·cp]`, putting the
   vertical component in the **northing** slot. `right()` crossed against
   `[0,1,0]`. `integrate_orbit` built its offset the same way. Net effect:
   **climbing moved the aircraft north** (and `to_lonlat()` reported a changed
   latitude), while **turning changed its altitude**.
2. `cinematic.rs` *documented* the render layout inside a `WorldPos` and built
   its orbit that way — so an autopilot "altitude" was silently a northing and
   the floating-origin snap quantised the wrong axis.
3. `facett-demo`'s `osm_fly.rs` and `bin/flight_demo.rs` stored the camera
   **pre-swapped** into a Y-up `WorldPos` to make it agree with (1).

Nothing caught it because **every rig test was basis-relative**: they compared
motion against `forward()` itself, so a consistently-wrong basis passed all of
them, and the crate had no `Facet` tying the rig back to the world frame. This
is the same failure shape as the `SCREEN_BOUND_K` smear: a suite that measures a
thing against itself.

### The chosen design

**`origin.rs` is the authority. There is one world frame, and exactly one swap,
at the render boundary.**

- `camera_fly::forward()` is `[cos(yaw)·cp, sin(yaw)·cp, sin(pitch)]`; `right()`
  crosses against world up `[0,0,1]`; `integrate_orbit` matches.
- `cinematic.rs` builds its orbit in the world frame (`z` is the lift axis).
- The demo binaries keep the camera in the world frame and apply `[v0, v2, v1]`
  **once**, to the camera basis, at the point where they already swap geometry.

### The oracle that pins it

`camera_fly::tests::rig_climbs_in_altitude_not_latitude` is deliberately **not**
basis-relative — it asks the world what happened:

- flying straight up gains > 99 m of `z` and moves < 2 m over the ground
  (`ground_distance`), where the pre-fix basis gained ~0 m of altitude and ~100 m
  of northing;
- level flight at yaw 0 increases the **longitude** and holds altitude exactly;
- `move_up` is a pure 250 m climb that moves you nowhere on the map.

The pane-level twin is `climbing_changes_the_published_altitude_not_the_latitude`
in `tests/facet_robot.rs`, which reads the same contract off `state_json`.

---

## 4. What this leaves open

- The `Walk` mode has no collision: a walker passes through building walls. The
  render core carries no per-footprint identity yet (the picking gap, D:84), and
  collision wants the same footprint index. Do picking first, then collision.
- `set_ground_m` is host-pushed. Wiring it to `terrain::elevation_at` inside the
  pane would make the walk follow real DEM relief without host cooperation.
- The pane drives `Map3D`'s orbit camera through bearing/tilt; a true 6DOF
  free-look (roll) is flattened, because the render core's camera is an orbit
  rig. A roll-capable core is a `facett-map3d` change, not a flight one.