**Note** from Bead: Firewatch Camera Grid · [canonical source](https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-24/notes/02-grid-layout-v8-sketch.md) · session 2026-06-24 · discussion: Talk: Firewatch Camera Grid
Sketched by Debbie (senior UX/UI). v8 builds **wholesale on the [v7 engine](01-grid-layout-v7-sketch.md)** — same one-scene / two-positions / `lerp(mapPos, gridPos, t)` mechanism, same `MAP ◀▶ GRID` scrubber, same straight-lerp + dim-ghost basemap, same pan/zoom + viewport-implicit selection via `effectiveCams()`, same crossings readout, same per-card un-pin/re-flow, same OrientedMap glyphs + tie-point red-ray, same TIME cursor, same zero element create/destroy during the morph. v8 **fixes one bug and removes one thing**: it replaces angle-around-center placement with **position-preserving** placement, and it **deletes the 180°/cinematography axis**. **Rendered sketch:** [https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-24/artifacts/sketches/grid-layout-transitions-v8.html](https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-24/artifacts/sketches/grid-layout-transitions-v8.html) *(build stamp `2026-06-24` + `v8` in the top bar — freshness marker, house convention.)*
## Stephen's feedback (verbatim — load-bearing) > **"ok, let's forget the cinematography 180-thing. it may be confusing > us. consider these three cameras selected. my expectation if you look at > their horizontal they would flow into the 3 positions like this: compare > instead the second screenshot with the crossings. why are they > crossing?"**
## The diagnosis (the bug this fixes) v6/v7 placed each camera's grid slot by its **compass bearing around the scene center** — the literal "angle from center" reading. That does **not** match a viewer's intuition, which is **horizontal screen order** (and 2D relative position generally). Worked example — three selected cameras whose **bearings** sort one way but whose **left-to-right screen order** sorts another: - Their bearings (around scene center) sort to NE, E, S. - Their **left-to-right on-screen order** is **S, NE, E**. - The two disagree, so a row-major "azimuth fill" sends each card to the wrong horizontal slot, and the straight-lerp paths **cross**. The v6 "minimize crossings" only minimized *within* the angular scheme, so it could never match the horizontal expectation — it was optimizing the wrong objective. **Verified with a harness against the real world coords + the real default view transform** (`grid-layout-transitions-v8.html`'s own `CAMS` for C2 Mesa / C3 Tower / C6 Lookout): screen dots come out C6 leftmost (x≈219), C2 center (x≈426), C3 right (x≈470). - **NAIVE** (bearing-around-center azimuth order C2>C3>C6 → slots 0,1,2): **1 crossing** — the bug Stephen saw. - **POSITION-matched** (C6→left, C2→center, C3→right): **0 crossings**, left-stays-left. ✓
## The fix — position-preserving placement `computePlacement()`'s `pos` branch (replacing v6/v7's angle-around-center block) is a **min-cost assignment over normalized on-screen positions**: 1. **Normalize** each effective camera's *current on-screen point* (under the pan/zoom transform — `mapDotPx(c)`) into a unit box `[0,1]×[0,1]` over the selected set's bounding box (`normPoints`). 2. **Normalize** each grid slot center into the same `[0,1]×[0,1]` box. 3. **Assign** cameras to slots by **minimizing total positional distance** — `minCostAssign` is **exact** (all permutations) for N ≤ 6 and a **greedy-nearest + 2-opt** refinement for larger N (correct and stable). Cost is squared normalized distance with x weighted slightly (×1.15) so the **horizontal expectation dominates ties** — *left stays left*. For a **single row** this reduces to a **pure horizontal (x) sort → zero crossings**, exactly Stephen's expectation. For **multiple rows** it preserves *both* axes (top→top, bottom→bottom as well as left→right). The crossings readout is unchanged and now goes to **0** for the NE/E/S case. The A/B demo toggle is re-labelled **NAIVE (row-major azimuth fill) ↔ POSITION-matched** so Stephen can flip and watch crossings drop. The faint map→grid path segments are kept so crossings stay visible.
## What was removed (deleted, not hidden) Per Stephen — *forget the 180-thing, it's confusing us*. Cleaned out entirely: - the **180° dashed axis line** + its `"180° axis"` text label, - the `.axis-note` panel + its element, and `axisNote` references, - the `axisAzimuth()` and `drawAxis()` functions (the line-of-action logic), - the "180° rule / 2D angular correspondence" legend + RATIFIED note text. `azFromScene()` survives in **one place only** — to drive the **NAIVE** demo branch (so the toggle still shows the *old* bug). Placement no longer uses bearing at all.
## Kept from v7 (not regressed) Single-scene lerp engine (one persistent node per camera, **zero create/destroy** in the morph — `render()` writes only node styles); real pan/zoom; viewport-implicit selection + explicit override (`effectiveCams()`); dim-ghost basemap underneath; straight-line lerp; `MAP ◀▶ GRID` scrubber; per-card un-pin + re-flow (now over position-matched slots); OrientedMap glyphs + tie-point red-ray read-through; 3D oblique at t≈0; preserved TIME cursor; always-on identity color; tether quiet-toggle default OFF; panel + fullscreen.
## The remaining fork (the only thing posed) — grid shape Position-preserving placement is the **fix** (shown, not posed). The one open choice is the **grid's shape**, answerable in-sketch (pick → it changes the live layout → send to the `request/` dock): - **A — aspect-follows-layout (FIRST-CUT).** Choose rows × cols to match the cameras' **spatial bounding-box aspect** (`camAspect()` → `gridCols()`). A near-vertical line of cameras becomes a vertical **column** rather than being forced into a wide row; a wide spread stays a wide row. Best preserves the spatial reading and **further reduces travel + crossings** (slot geometry tracks camera geometry). - **B — fixed (≤3 cols, reading order).** Always pack into ≤3 columns, top-to-bottom reading order, regardless of spread. Predictable, dense, familiar — but a vertical line of cameras is forced into a wide row. **First-cut (a fork, not a verdict): A — aspect-follows-layout.** It best preserves the spatial reading and minimizes travel/crossings because the slot block mirrors the camera block; B is more predictable/dense. The answer card PUTs the pick to the `request/` dock as a `sketch-answer` (`sketch:'grid-layout-v8'`), carrying `resolved` forward — the 180/angle entries **dropped**, `placement:'position-preserving'` **added**, `continuity:'map-grid-lerp'`, `lerp-path:'straight'`, `basemap:'dim-ghost-underneath'`, `empty-selection:'use-cameras-in-view'` **kept**.
## The transition the ▶ tour plays Select the **NE/E/S trio** (Mesa, Tower, Lookout) → flip **NAIVE** and scrub to grid to **see them cross** (the bug) → flip **POSITION-matched** and watch **crossings drop to 0** (left stays left) → scrub home and back (cards flow straight) → brush each card (identity color persists) → clear the pick → falls back to viewport (auto in-view). *One scene, two positions, straight lerp — now the cards land where the eye expects them.*
## Status First-cut. Bug **diagnosed and fixed** (position-preserving min-cost assignment over normalized on-screen positions; single row = horizontal sort → 0 crossings; both axes preserved for multi-row). 180°/cinematography axis **fully removed** (line, label, note, functions, legend text). `node --check` passes on the extracted script; the morph path creates/destroys **no** elements (`render()` writes only node styles). Harness against the real coords + view transform confirms NAIVE = 1 crossing, POSITION = 0 crossings, left→left monotonic for the NE/E/S case. Remaining fork = grid shape (aspect-follows-layout first-cut). Awaiting Stephen's fork choice via the `request/` dock.