From 37c35b21cdb1831f3963d395e2b834196a6b04d4 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Fri, 2 Oct 2026 18:13:08 +0200 Subject: [PATCH] [M2-ISO-03] Isometric picking (screen -> grid cell) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine-owned safe screen -> ground-plane -> grid-cell transform (FR-2.11, PRD 4 click-to-select/move; S-5/G-R11): - iso_picking.h (header-only): IsoGridConfig, IsoGridPick, and screenToGrid(screen, camera, grid) — the O(1) 2x2 inverse of the M2-CAM-02 camera matrix (no per-pick 4x4 inverse, no allocation), plus a ProjectionView overload for hand-stored iso matrices (the M2-PROJ-01 base). - Documented boundary rule: half-open cells [gx*g, (gx+1)*g) x [gy*g, (gy+1)*g) (floor; exact boundary -> the forward cell, the corner -> the upper-right cell); the grid anchored at the world origin (the tile map's tile (gx, gy) at g = 1 — the grid the M2-CAM-02 grid-snap camera locks to). - Exact at all supported zoom levels (a fixed float sequence — zoom enters only through the matrix entries); documented precision zone 16*2^-24*kappa*(|e| + |w|) (~6e-4 world units at scene scale; kIsoPickDomainBoundaryEps ~ 4.0 in the domain worst case). - Total function: non-finite screen saturates at the documented world domain (+inf -> +32767, -inf/NaN -> -32767 — the isoDepthKey convention); a stopped camera picks with the identity matrix (the M2-CAM-02 stopped-state contract). No error path, no per-call assert. Tests (iso_picking_tests.cpp; ctest -R iso_picking): - hand-computed golden cells at 4 zoom levels (2:1 dimetric, plus a camera-offset case) and true 30/60 at 2 zooms; - the roadmap property: 4096 cells x 4 zooms = 16384 picks >= 10k — screen_to_grid(world_to_screen(cell_center)) == cell — plus the ground within 1e-3 and the zero-allocation proof (1000 picks = 0 heap blocks under the allocation watch); - the boundary suite (half-open rule at 0.05 off the boundaries, the exact-boundary/corner zone membership, the g = 2 scaling); - a supported custom shear (round trip + 256-cell property); the total-function saturation; the stopped-camera degenerate pick; the ProjectionView overload parity. Budget: the PRD 8.1 iso_picking entry (one pick mean <= 0.01 ms) is gated on the Linux non-instrumented trees (LAIGE_ISO_PICK_BUDGET — the iso_depth_table precedent). Measured 0.000136262 ms mean on the canonical Debug tree (n = 3000; 73x inside the gate); the recorded value retires the M0 convention in budgets.json. Baseline: docs/benchmarks/baselines/m2-iso-picking.md (the sixth baseline file; cross-compiler clang -O0 run 0.000189456 ms — 53x inside). Docs: docs/api/iso_picking.md (the full API contract), docs/concepts/coordinates.md 5.1 (the canonical home: the inverse formula, the boundary rule, the precision zone), docs/README.md API index, src/laige-render/README.md status. laige-api.json regenerated (1106 symbols / 34 headers — +13); api-real-tree/api-check-fresh, include-lint, and determinism-lint green. All six local trees verified (build, build-clang, build-release, build-shared, build-asan, build-tsan — ctest -R iso_picking and the full laige-render_tests green, no new warnings). Progress Board M2 11/33, total 57/194. --- budgets.json | 2 +- docs/README.md | 9 + docs/api/iso_picking.md | 223 +++++++ docs/benchmarks/baselines/m2-iso-picking.md | 162 +++++ docs/concepts/coordinates.md | 56 +- laige-api.json | 14 + roadmap/M2-rendering-2.5d.md | 2 +- roadmap/README.md | 5 +- src/laige-render/README.md | 25 + .../include/laige/render/iso_picking.h | 311 ++++++++++ tests/laige-render/CMakeLists.txt | 55 +- tests/laige-render/iso_picking_tests.cpp | 575 ++++++++++++++++++ 12 files changed, 1420 insertions(+), 19 deletions(-) create mode 100644 docs/api/iso_picking.md create mode 100644 docs/benchmarks/baselines/m2-iso-picking.md create mode 100644 src/laige-render/include/laige/render/iso_picking.h create mode 100644 tests/laige-render/iso_picking_tests.cpp diff --git a/budgets.json b/budgets.json index 2b9bbba..ac795f7 100644 --- a/budgets.json +++ b/budgets.json @@ -55,7 +55,7 @@ "metric": "mean", "unit": "ms", "target": 0.01, - "measured": 0, + "measured": 0.000136262, "workload": "one isometric screen-to-grid pick, O(1) (PRD 8.1)" }, { diff --git a/docs/README.md b/docs/README.md index eaaead4..057583f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -247,6 +247,15 @@ still to land. allocation-free; the documented round-trip precision and the per-mode preimage geometry; the NDC↔pixel boundary) (M2-PROJ-01; `laige-render`). +- [Isometric grid picking](api/iso_picking.md) — + `laige::render::screenToGrid`: the engine-owned safe screen → + ground-plane → grid-cell inverse of the isometric projection + (FR-2.11, on top of the M2-PROJ-01 transforms): the O(1) 2×2 inverse + of the M2-CAM-02 camera matrix, the documented half-open cell + boundary rule and precision zone (exact at all supported zoom + levels), the total-function saturation contract, and the PRD §8.1 + budget (one pick mean ≤ 0.01 ms — the `iso_picking` entry) + (M2-ISO-03; `laige-render`). ## Guides diff --git a/docs/api/iso_picking.md b/docs/api/iso_picking.md new file mode 100644 index 0000000..d8d9d17 --- /dev/null +++ b/docs/api/iso_picking.md @@ -0,0 +1,223 @@ +# Isometric grid picking (`laige::render::screenToGrid`) + +The engine-owned, safe screen → ground-plane → grid-cell transform +(M2-ISO-03; FR-2.11, PRD §4 click-to-select/move; AGENTS ARCH-008/ +009, API-001/008, CORE-005/008, PERF-003, DOC-004, S-5/G-R11). Public +header: `src/laige-render/include/laige/render/iso_picking.h` +(header-only — the inverse is a fixed sequence of float ops; there is +no implementation file). Unit suite: `ctest -R iso_picking` +(`tests/laige-render/iso_picking_tests.cpp`) — pure float math, no GL +environment required: it runs in every local tree and in CI, with +hand-computed golden cells at 4 zoom levels, the 10k-cell × 4-zoom +round-trip property, the boundary/precision pinning, the total- +function saturation contract, and the PRD §8.1 one-pick budget gate. + +The picking is the M2-PROJ-01 screen↔world base applied to the +isometric grid: it inverts the frame's **stored** world → NDC matrix +(the M2-CAM-02 `IsoCamera::matrix()` or an Iso `ProjectionView` +matrix) on the ground plane (z = 0) and maps the ground point to its +grid cell. Game code never inverts the iso matrix itself (S-5, G-R11: +the engine owns the picking math, just as it owns the depth keys — +PRD §4). + +## The API + +```cpp +struct IsoGridConfig { float cellSize{1.0f}; }; // world units per cell + +struct IsoGridPick { + std::int32_t cellX{}; // the cell's x index (floor convention) + std::int32_t cellY{}; // the cell's y index (floor convention) + Vec2 ground{}; // the (saturated) ground-plane world point +}; + +// The NDC screen point -> the ground plane (z = 0) -> the grid cell. +[[nodiscard]] IsoGridPick screenToGrid(Vec2 screen, + const IsoCamera& camera, + const IsoGridConfig& grid); +// The same inverse over a hand-stored iso matrix (advanced use). +// Precondition: view.mode == ProjectionMode::Iso. +[[nodiscard]] IsoGridPick screenToGrid(Vec2 screen, + const ProjectionView& view, + const IsoGridConfig& grid); +``` + +- **`screen`** is the screen point in **NDC** (the projection.h + convention — NDC x right, y up, `[-1, 1]²`). The window pixel → NDC + conversion is the game's input boundary (RENDER-006: the window is a + platform detail): `ndc = (px / W * 2 - 1, 1 - py / H * 2)`. +- **`camera`** is the M2-CAM-02 `IsoCamera` in its CURRENT state + (position, zoom, shake — the camera's `matrix()` is rebuilt on the + pick, O(1), so the pick always uses the current frame matrix). +- **`grid`** is the pick grid: `cellSize` in world units (the tile + map's grid is `cellSize = 1`). A valid config is finite, `> 0`, and + `>= kIsoPickMinCellSize` (1e-4) — a **config precondition** (the + matrices.h house convention: a violation is a programmer error — + debug assert, release undefined behavior), not a runtime error. +- **The result**: `cellX`/`cellY` are the grid cell indices; `ground` + is the computed ground-plane world point (world units) — the + diagnostics / proximity-query view of the pick. + +## The inverse (O(1), FR-2.11) + +For the frame matrix `M` (column-major `m[c][r]`; rows 0/1 are the +NDC x/y rows, row 3 the translation): + +``` +a = m[0][0], b = m[1][0], c = m[0][1], d = m[1][1], +tx = m[3][0], ty = m[3][1] + +det = a*d - b*c (the ground map's determinant) +w.x = (d*(ndc.x - tx) - b*(ndc.y - ty)) / det +w.y = (a*(ndc.y - ty) - c*(ndc.x - tx)) / det +``` + +One pick is a few flops: no per-pick 4×4 inverse, no GLM calls, no +allocation. `det != 0` is the `isoMatrix`/`IsoCamera` precondition +(the ground map is invertible — matrices.h). The solve is the exact +inverse of the **stored float matrix**: the pick resolves to the cell +of the projection the frame actually renders with — never a different, +"ideal" matrix. + +**Exact at all supported zoom levels.** The inverse is a fixed +sequence of float ops with no zoom-dependent branch or table — zoom +enters only through `M`'s entries (the M2-CAM-02 matrix build) — so +the same stored screen point resolves to the same cell at every zoom +the camera supports: the continuous `[zoomMin, zoomMax]` range +without snap, or the dyadic zoom ladder in snap mode. The goldens pin +4 zoom levels (`0.25, 1, 4, 16`) by hand for both built-in presets, +plus a camera-offset case. + +## The grid cell: boundary rule and precision (FR-2.11) + +**The boundary rule** (documented; pinned by +`IsoPickBoundary.HalfOpenCellsAndBoundaryZone`): cell `(gx, gy)` is +the half-open square + +``` +[gx*g, (gx+1)*g) x [gy*g, (gy+1)*g) i.e. gx = floor(w.x / g) +``` + +A point exactly on a cell's lower or left boundary belongs to THAT +cell; exactly on its upper or right boundary to the cell beyond it +(the floor convention); a corner to the cell to its upper right. The +grid is anchored at the world origin — the tile map's tile `(gx, gy)` +(M2-TILE-01) is exactly this cell at `g = 1`, with center +`(gx + 0.5, gy + 0.5)`. This is the same grid the M2-CAM-02 grid-snap +camera locks its position to. + +**The precision (the boundary zone).** The computed ground point +`ŵ` satisfies + +``` +|ŵ − w| <= kIsoPickOpFactor * kIsoPickUlp * kappa * (|e| + |w|) +``` + +(world units, per axis), where `w` is the exact ground point of the +exact inverse, `e` is the camera's ground point +(`effectivePosition() = position + shakeOffset`), and `kappa` is the +∞-norm condition number of the UNSCALED ground 2×2 `[[dx.x, dy.x], +[dx.y, dy.y]]` (a preset property, scale-invariant: **4.5** for 2:1 +dimetric, **~2.73** for true 30/60). The constants (CORE-005): +`kIsoPickUlp = 2⁻²⁴` (the per-op float rounding unit), +`kIsoPickOpFactor = 16` (the 10 rounding ops of the fixed sequence + +margin over the backward-error bound). Consequence (the boundary +guarantee): + +- a pick returns the **exact cell of the exact inverse** unless the + exact ground point lies within that bound of a cell boundary; +- inside that zone either of the two adjacent cells may be returned — + the float rounding decides, **deterministic per build** (never a + random flip between frames of the same stored matrix); +- the zone is **~6e-4 world units at scene scale** (`|e|, |w| <= 64.5`, + a built-in preset — the property test's 0.5-cell margin is ~900x + it), and `kIsoPickDomainBoundaryEps` (~4 world units) in the + domain worst case (`kappa <= kIsoPickMaxCondition = 64` — 14x the + built-ins — and `|e| = |w| = 32767`); +- a custom shear with `kappa > 64` still picks (the inverse is total + over every invertible shear) but its zone scales linearly in + `kappa`. + +## Total function, domain, and saturation (CORE-008) + +`screenToGrid` is **total**: it returns a pick for every input — no +error path, no per-call assert (a per-click pick is called from input +handling with untrusted data; a failed click must not be an engine +failure the game handles — the `isoDepthKey` total-function +precedent): + +- non-finite screen components propagate through the fixed float + sequence and the ground point **saturates** at the documented + world domain (`kIsoDepthMaxWorldUnits = 32767`, iso_depth_key.h): + `+inf → +bound`, `-inf` and `NaN → -bound` (IEEE: NaN compares + false — the isoDepthKey saturation convention); +- the result cell is then within `|gx|, |gy| <= 32767/g` — inside + `int32` for every valid `g` (`32767 / 1e-4 = 3.3e8 < 2³¹−1`). + +A **stopped** (invalid) `IsoCamera` picks with the identity matrix +(the M2-CAM-02 stopped-state contract: `matrix()` is the identity): +`cell = floor(screen / g)` — degenerate but total; the game checks +`valid()` before relying on the pick (pinned by +`IsoPickStoppedCamera.IdentityDegenerate`). + +## Ownership, lifetime, threading + +- **Ownership/lifetime:** plain value types — `IsoGridConfig`, + `IsoGridPick` are trivially copyable; nothing to own or release. + The function is a pure query: it owns nothing, borrows the camera + (const reference, caller-owned) and the grid config (by value). +- **Threading:** the pick is a pure function of its inputs — callable + from any thread (input handling, the render set-up phase). It + performs no mutation: a concurrent camera UPDATE is a data race on + the camera's state (the camera's single-owner contract, + docs/api/camera.md, CONC-001) — pick between camera mutations, as + the frame pipeline phases it. +- **Presentation-only** (ARCH-009): reads no sim state, writes + nothing; never part of replay state or the simulation state hash. + Determinism scope: **per build** — a fixed sequence of float ops + (render-side float, NOT SimMath — the ADR 0002 pinned-math contract + does not apply; no RNG, no clock). Same (screen, matrix, grid) → + bit-identical pick on the same build/platform. + +## Performance (PERF-002/003, DOC-004) + +- **Complexity: O(1)** — 10 rounding ops + 2 exact `floorf` + the + camera's O(1) matrix build. One pick: no allocation, no logging, + no GL calls (disabled cost is zero). The measured single-pick cost + on the reference platform: **0.000136 ms mean** (the `iso_picking` + budget, PRD §8.1 — target 0.01 ms; baseline + [m2-iso-picking.md](../benchmarks/baselines/m2-iso-picking.md)). +- **Call site:** the pick is a **per-click / per-query** call in input + handling — NEVER per sprite or per frame. Batch picks (e.g. a + drag-select region) are one call per screen point; there is no + higher-level batch form (there is no per-frame pick work to batch). +- **Blocking/IO/GPU:** none — pure float arithmetic. + +## Determinism and network-authority implications + +The pick is presentation-only: it never mutates authoritative state. +A pick result is a *client* input (the game decides what a clicked +cell means); replicating the pick's INPUT (the screen point / cell) — +not the authoritative consequence — is the standard +client-authority pattern (PRD §4: click-to-select/move). Because the +pick is deterministic per build for a given stored matrix, a replayed +frame + the same click reproduces the same cell on the same build +(no cross-platform promise — the float ops are render-side). + +## Misuse warnings + +- **The screen is NDC, not window pixels** — convert with the + documented one-liner first (RENDER-006). Picking with pixel + coordinates picks the wrong cell by an order of magnitude. +- **Do not pick through a stopped camera** (check `valid()`) — the + identity-matrix degenerate pick is total but meaningless; and not + through a non-Iso `ProjectionView` (the overload's precondition: + the 2×2 solve assumes the iso row structure). +- **The cell is on the GROUND plane (z = 0)** — a click on raised + terrain picks the cell of its ground projection (the standard + isometric click-to-select semantics). The tile-height-aware pick + (the standing cell of the clicked tile) lands with the tile map + (M2-TILE-01/02), which consumes this inverse. +- **Config once, pick often**: build the `IsoGridConfig` with the + scene (the grid's cell size is a scene constant); the pick itself + is the hot input path. diff --git a/docs/benchmarks/baselines/m2-iso-picking.md b/docs/benchmarks/baselines/m2-iso-picking.md new file mode 100644 index 0000000..d4135ce --- /dev/null +++ b/docs/benchmarks/baselines/m2-iso-picking.md @@ -0,0 +1,162 @@ +# Baseline: `m2-iso-picking` — M2-ISO-03 one-pick budget + +Recorded by **M2-ISO-03** (2026-10-02). This is the **sixth** baseline +file; it is immutable (methodology §4 — superseding it later adds a +new file, it is never edited). It retires the `0 = not yet measured` +M0 convention for the **`iso_picking`** budget (one isometric +screen-to-grid pick, mean ≤ 0.01 ms, PRD §8.1). + +## What this baseline measures + +The `IsoPickBudget` suite of `laige-render_tests` (M2-ISO-03; the +suite is in `tests/laige-render/iso_picking_tests.cpp`): the PRD §8.1 +"Isometric picking" workload — **one isometric screen-to-grid pick, +O(1)** — checked against the **`iso_picking`** budget (mean ≤ 0.01 ms). + +Workload shape: + +- One measured sample is **one `screenToGrid` call**: a 2:1 dimetric + `IsoCamera` (default options — scale 1, snap off, zoom 1, camera at + the origin) and the default grid (`cellSize = 1`), the pick of one + NDC screen point (the budgets.json unit — no SimMath backend is + involved: the inverse is render-side float, ADR 0002 does not + apply). +- The 3 000 NDC points are **precomputed** outside every measured + window (deterministic `i % 97` / `i % 89` lattice — no RNG in the + measured path, no division in the harness, the + `iso_depth_table_tests.cpp` discipline): the measured loop is the + pick's own 2×2 solve + two `floorf` + the camera's O(1) matrix + build. +- **Warm-up:** 100 picks discarded; **measured:** 3 000 picks + (`n=3000`), histogram capacity 3 000, no truncation. + +The gate (roadmap M2-ISO-03 Verify): mean ≤ 0.01 ms, enforced on CI +by the `iso_picking` ctest entry. The gated branch (load +`budgets.json`, `budgetCheck`, the stable 4-line report, +`EXPECT(passed)`) compiles only on **Linux non-instrumented trees** +(`LAIGE_ISO_PICK_BUDGET` — the `iso_depth_table_tests.cpp` +precedent): instrumentation inflates the absolute cost (methodology +§4), so the sanitizer trees run the **same workload ungated** and +verify its safety properties instead (leak-free ASan, race-free +TSan); the absolute target is reference-platform-scoped +(methodology §5), and the P0 `linux-clang` job runs on the same +reference runner, so the gate is verified there too (the cross- +compiler run below). The zero-allocation property of the pick is +pinned separately by `IsoPickProperty` (1 000 consecutive picks = 0 +heap blocks under the process-wide allocation watch, the non- +sanitizer trees). + +## AGENTS §12 metadata + +| # | Field | Value | +|---|---|---| +| 1 | Hardware | AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM | +| 2 | OS | CachyOS (Arch-based Linux), kernel `7.2.6-1-cachyos`, x86_64 | +| 3 | Compiler and version | `g++ (GCC) 16.2.1 20260810` (canonical gate run; the cross-compiler run below uses `Clang 22.1.8`) | +| 4 | Build type | `Debug` (canonical, `build/` tree — CMake Debug: `-O0 -g` + the engine policy flags) | +| 5 | Relevant flags | Engine policy (NFR-8.10): `-Wall -Werror -fno-exceptions -fno-rtti`. The workload is render-side float (no SimMath TU in the measured path — the ADR 0002 pinned set does not apply). No sanitizers (canonical tree). | +| 6 | Dataset / workload | `iso_picking` — one `screenToGrid` pick: default 2:1 dimetric `IsoCamera` (scale 1, snap off, zoom 1, e = 0), `cellSize = 1`, one precomputed NDC point per sample (3 000-point deterministic lattice) | +| 7 | Warm-up | 100 picks discarded | +| 8 | Sample count | `n=3000` picks (histogram capacity 3000, no truncation) | +| 9 | Summary statistics | min=0.00011 mean=**0.000136262** p50=0.000131 p95=0.00016 **p99=0.00018** max=0.00034 ms | +| 10 | Before / after | `before=0` (M0 convention — not yet measured) · `after` (mean): **0.000136262** → **recorded 0.000136262** · `target`: mean ≤ 0.01 ms — **PASS** (73× inside the gate) | + +## Verbatim run output + +### Run — canonical tree (Debug, g++), budget-checked + +Command (run from the repository root; `budgets.json` resolved via +`LAIGE_BUDGETS_PATH`): + +```console +$ LAIGE_BUDGETS_PATH=$PWD/budgets.json \ + ./build/bin/laige-render_tests --gtest_filter='IsoPickBudget*' +``` + +```text +budget=iso_picking result=PASS metric=mean unit=ms + after=0.000136262 before=0 target=0.01 + stats: n=3000 min=0.00011 mean=0.000136262 p50=0.000131 p95=0.00016 p99=0.00018 max=0.00034 + context: workload=one isometric screen-to-grid pick, O(1) (PRD 8.1) build=GCC 16.2.1 20260810, CMake Debug, engine policy (NFR-8.10) machine= warmup=100 +``` + +Exit code: `0`. (The report's `before=0` is the first-ever +measurement of this budget — the M0 convention; subsequent runs +carry the recorded `measured`.) + +### Gate — CI shape, canonical tree, `ctest -R iso_picking` + +```console +$ ctest --test-dir build -R "iso_picking" --output-on-failure +``` + +```text +Test project /home/anon/devel/laige-cpp/build + Start 45: iso_picking +1/1 Test #45: iso_picking ...................... Passed 0.01 sec + +100% tests passed out of 1 + +Total Test time (real) = 0.02 sec +``` + +Commit: the M2-ISO-03 commit on branch `feat/m2-iso-03-iso-picking` +(the `iso_picking` ctest entry is the CI perf lane — the P0 jobs run +the full `ctest` on the canonical tree and the `linux-clang` job). + +## Cross-compiler run (same workload, same n=3000, clang -O0) + +The gate is reference-platform-scoped (methodology §5), and the P0 +`linux-clang` CI job runs on the same reference runner — so the gate +must clear the 0.01 ms bar on the clang -O0 tree as well. Recorded as +the cross-compiler evidence (the canonical-tree run above is the +recorded value; this run is the CI-shape check): + +```text +budget=iso_picking result=PASS metric=mean unit=ms + after=0.000189456 before=0.000136262 target=0.01 + stats: n=3000 min=0.00016 mean=0.000189456 p50=0.00019 p95=0.000211 p99=0.00023 max=0.005391 + context: workload=one isometric screen-to-grid pick, O(1) (PRD 8.1) build=Clang 22.1.8, CMake Debug, engine policy (NFR-8.10) machine= warmup=100 +``` + +**PASS** on clang -O0 (0.000189 ms mean — 53× inside the gate). + +## Interpretation + +- **73× inside the gate on the canonical tree, 53× on clang -O0.** + The 0.01 ms (10 µs) PRD number was written for an O(1) pick; at + -O0 (where the gate measures, methodology §4) one pick costs + ≈ 136 ns (canonical g++) / ≈ 189 ns (clang) on this machine — a + 2×2 solve, two `floorf`, and the camera's O(1) matrix build. +- **Tail behavior:** p99 0.00018 ms (canonical) — 1.3× the mean. The + clang run's single `max=0.005391 ms` sample is a scheduler/ + preemption outlier on the shared 32-thread machine, still 995× + inside the gate (the p99 is the perf-regression watch — PERF-009). +- **Zero per-pick allocation is structural** (a fixed sequence of + float ops — no containers, no logging, no GL) and pinned by + `IsoPickProperty` (1 000 consecutive picks = 0 heap blocks under + the process-wide allocation watch). +- **Steady state, deterministic:** the 100-pick warm-up plus the + precomputed point lattice keep the workload L2-hot and free of any + RNG or division in the measured path — the run is reproducible and + independent of any scene content. + +## Regression policy + +`budgets.json` `measured = 0.000136262` (the canonical Debug tree, +g++ — the workload is backend-independent, so no worse-of-two +selection applies); the 10% regression band +(docs/benchmarks/methodology.md §5) applies to future re-measurements +on the reference platform. + +## Open items + +- **M2-TILE-01/02** builds the terrain-height-aware pick on this + inverse (the standing cell of the clicked tile — the ground- + projection pick of this step is the default click semantics). +- **M2-SAMPLE-01** wires click-to-select into the isometric template + (input screen point → `screenToGrid` → the selected cell; the + sample's picking test case prints the expected cell). +- The **input milestone** owns the screen-point source (mouse state → + the pixel → NDC conversion at the documented input boundary, + RENDER-006); this step consumes NDC by contract. diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index ff20f2e..7ce65ee 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -218,9 +218,60 @@ per-frame `isoDepthKey` (§4.1); the table serves the static tile grid | World → depth key | sim state → `uint32` key | `laige-render` (`isoDepthKey`, M2-ISO-01) | **This document / shipped** | | Tile grid → depth key table | tile heights → precomputed per-tile keys | `laige-render` (`IsoDepthKeyTable`, M2-ISO-02) | **This document / shipped** | | Depth key → render order | key (+ entity id) → sorted batches | M2-SORT-01 (stable radix sort), M2-SPRITE-01 (batcher) | planned | -| Screen → world (per mode) | picking, screen↔world transforms | `laige-render` (`ProjectionView`: `worldToScreen`, `screenToWorldRay`, `screenToWorld`, M2-PROJ-01), M2-ISO-03 (iso grid picking) | **Shipped (M2-PROJ-01)** / M2-ISO-03 planned | +| Screen → world (per mode) | picking, screen↔world transforms | `laige-render` (`ProjectionView`: `worldToScreen`, `screenToWorldRay`, `screenToWorld`, M2-PROJ-01; `screenToGrid` iso grid picking, M2-ISO-03) | **Shipped (M2-PROJ-01 + M2-ISO-03)** | | World → screen (render) | sim state → NDC → pixels | camera + preset matrix (M2-CAM-01/02, M2-GL-03), `ProjectionView::worldToScreen` (M2-PROJ-01), sprite draw (M2-SPRITE-02) | shipped to NDC (matrices, camera core M2-CAM-01, iso presets + grid-snap M2-CAM-02, world→screen transform M2-PROJ-01); pixels: M2-SPRITE-02 planned | +### 5.1 The isometric grid picking (M2-ISO-03) + +The safe screen → grid-cell transform (FR-2.11; PRD §4's +click-to-select / click-to-move): `screenToGrid(screen, camera, grid)` +in [`laige/render/iso_picking.h`](../../src/laige-render/include/laige/render/iso_picking.h) +— engine-owned (S-5, G-R11: game code never inverts the iso matrix +itself). The screen point is **NDC** (the projection.h convention — +the documented pixel ↔ NDC conversion is the input boundary, +RENDER-006); the grid plane is the ground plane (z = 0). The inverse +is the O(1) 2×2 solve on the frame matrix's ground rows (no per-pick +4×4 inverse): + +``` +det = a·d − b·c +w.x = (d·(ndc.x − tx) − b·(ndc.y − ty)) / det +w.y = (a·(ndc.y − ty) − c·(ndc.x − tx)) / det +``` + +- **The grid cells are half-open** (the documented boundary rule): + cell `(gx, gy)` is `[gx·g, (gx+1)·g) × [gy·g, (gy+1)·g)` — i.e. + `gx = floor(w.x / g)`. A point exactly on a cell's lower or left + boundary belongs to THAT cell; exactly on its upper or right + boundary to the cell beyond it; a corner to the cell to its upper + right. The grid is anchored at the world origin: the tile map's + tile `(gx, gy)` (M2-TILE-01) is exactly this cell at `g = 1`, with + center `(gx + 0.5, gy + 0.5)` — the grid the M2-CAM-02 grid-snap + camera locks to. +- **Exact at all supported zoom levels:** the inverse is a fixed + sequence of float ops — zoom enters only through the matrix's + entries — so the same stored screen point resolves to the same + cell at every zoom the camera supports (the continuous + `[zoomMin, zoomMax]` range without snap, the dyadic ladder with + snap). +- **Precision (the boundary zone):** the computed ground point + `ŵ` is within `16·2⁻²⁴·κ·(|e| + |w|)` world units of the exact + ground point (`κ` = the ∞-norm condition number of the ground 2×2: + 4.5 for 2:1 dimetric, ~2.73 for 30/60 — constants in + `iso_picking.h`). A point is guaranteed to resolve to its exact + cell unless its exact ground coordinate lies within that bound of a + cell boundary; inside the zone either of the two adjacent cells may + be returned (the float rounding decides — deterministic per build). + Domain-worst zone: `kIsoPickDomainBoundaryEps` (~4 world units at + `|e| = |w| = 32767`, `κ` ≤ 64). +- **Total function:** non-finite screen input saturates at the + documented world domain (±32767; NaN → lower bound — the + isoDepthKey convention) and the result cell fits int32 for every + valid cell size (`g >= kIsoPickMinCellSize = 1e-4`). A stopped + camera picks with the identity matrix (degenerate but total). +- **Budget:** one pick mean ≤ 0.01 ms (PRD §8.1, `iso_picking`) — + [baselines/m2-iso-picking.md](../benchmarks/baselines/m2-iso-picking.md). + Rules: - **Sim never sees projection.** No projection mode, camera, or screen @@ -252,6 +303,9 @@ state → same keys → same order, every frame (RENDER-003). contract (M2-ISO-01). - [`api/projection.md`](../api/projection.md) — the projection modes and screen↔world transforms contract (M2-PROJ-01). +- [`api/iso_picking.md`](../api/iso_picking.md) — the isometric grid + picking contract: `screenToGrid` (screen → ground → grid cell) + (M2-ISO-03). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index 7fb6b80..ae98588 100644 --- a/laige-api.json +++ b/laige-api.json @@ -19,6 +19,7 @@ "src/laige-render/include/laige/render/iso_camera.h", "src/laige-render/include/laige/render/iso_depth_key.h", "src/laige-render/include/laige/render/iso_depth_table.h", + "src/laige-render/include/laige/render/iso_picking.h", "src/laige-render/include/laige/render/matrices.h", "src/laige-render/include/laige/render/projection.h", "src/laige-sim/include/laige/sim/archetype.h", @@ -663,6 +664,19 @@ {"name": "laige::render::IsoDepthKeyTable::CellRecord::key", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_table.h", "line": 489, "signature": "std::uint32_t key{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::IsoDepthKeyTable::CellRecord::qBase", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_table.h", "line": 490, "signature": "std::int32_t qBase{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::IsoDepthKeyTable::CellRecord::height", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_depth_table.h", "line": 491, "signature": "std::int32_t height{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kIsoPickUlp", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 192, "signature": "inline constexpr float kIsoPickUlp = 5.9604644775390625e-8f", "summary": "The float rounding unit (2^-24): the per-op relative error bound of one IEEE-754 binary32 op (CORE-005; the precision formula below).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoPickOpFactor", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 196, "signature": "inline constexpr float kIsoPickOpFactor = 16.0f", "summary": "The rounding-op count of the picking inverse's fixed sequence (10 rounding ops: the 2x2 solve + the two cell divisions) plus margin (CORE-005; the precision formula below).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoPickMaxCondition", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 201, "signature": "inline constexpr float kIsoPickMaxCondition = 64.0f", "summary": "The precision contract's condition-number bound (the header preamble): the built-in presets are kappa = 4.5 (2:1) / ~2.73 (30/60); a custom shear with kappa <= 64 keeps the documented boundary zone, and beyond it the zone scales linearly in kappa.", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoPickDomainBoundaryEps", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 205, "signature": "inline constexpr float kIsoPickDomainBoundaryEps = kIsoPickOpFactor * kIsoPickUlp * kIsoPickMaxCondition * (2.0f * static_cast(kIsoDepthMaxWorldUnits))", "summary": "The domain-worst boundary ambiguity zone (the header preamble): kIsoPickOpFactor * kIsoPickUlp * kIsoPickMaxCondition * (2 * kIsoDepthMaxWorldUnits) world units (~4.000061).", "budget": null, "experimental": false}, + {"name": "laige::render::kIsoPickMinCellSize", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 211, "signature": "inline constexpr float kIsoPickMinCellSize = 1e-4f", "summary": "The minimum grid cell size (world units): below it the saturated cell index 32767/g can leave int32 (CORE-005: 32767/1e-4 = 3.3e8 < 2^31 - 1 with 6.5x margin).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoGridConfig", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 217, "signature": "struct IsoGridConfig", "summary": "The pick grid (FR-2.11's grid_config): the cell size in world units (finite, > 0, >= kIsoPickMinCellSize — a config precondition, the header preamble). 1.0 world unit per cell is the tile map's grid (M2-TILE-01).", "budget": null, "experimental": false}, + {"name": "laige::render::IsoGridConfig::cellSize", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 218, "signature": "float cellSize{1.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoGridPick", "kind": "struct", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 225, "signature": "struct IsoGridPick", "summary": "The pick result (the safe API — S-5/G-R11): the grid cell (the floor convention, the header preamble) + the computed ground-plane point (world units; the diagnostics / proximity-query view of the pick). A plain value — no ownership, nothing to release.", "budget": null, "experimental": false}, + {"name": "laige::render::IsoGridPick::cellX", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 226, "signature": "std::int32_t cellX{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoGridPick::cellY", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 227, "signature": "std::int32_t cellY{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::IsoGridPick::ground", "kind": "variable", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 228, "signature": "Vec2 ground{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::screenToGrid", "kind": "function", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 280, "signature": "[[nodiscard]] inline IsoGridPick screenToGrid(Vec2 screen, const IsoCamera& camera, const IsoGridConfig& grid) noexcept", "summary": "The safe isometric pick (FR-2.11; the header preamble is the full contract): the NDC screen point -> the ground plane (z = 0) -> the grid cell. O(1) (the 2x2 solve + two divisions + the camera's O(1) matrix build); total for every input (the header preamble's saturation section); no allocation, no logging, no GL.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::render::screenToGrid", "kind": "function", "header": "src/laige-render/include/laige/render/iso_picking.h", "line": 298, "signature": "[[nodiscard]] inline IsoGridPick screenToGrid(Vec2 screen, const ProjectionView& view, const IsoGridConfig& grid) noexcept", "summary": "The ProjectionView overload (the M2-PROJ-01 base this step lands on — the header preamble): the same inverse over a hand-stored iso matrix (advanced use: e.g. a custom shear through the raw isoMatrix builder, M2-CAM-02). Precondition: view.mode == ProjectionMode::Iso (the per-mode structure assumption — the matrices.h house convention: debug assert, release undefined).", "budget": "O(1); no allocation.", "experimental": false}, {"name": "laige::render::Vec2", "kind": "alias", "header": "src/laige-render/include/laige/render/matrices.h", "line": 130, "signature": "using Vec2 = glm::vec2", "summary": "The GLM value types of the rendering-side math API (PRD §11). GLM is pinned in deps.lock (ADR 0008) and exposed through this header only — the include-graph lint (R3) forbids every other module from including it, so the sim modules' SimMath (PRD §10.3) stays the sim-side math.", "budget": null, "experimental": false}, {"name": "laige::render::Vec3", "kind": "alias", "header": "src/laige-render/include/laige/render/matrices.h", "line": 131, "signature": "using Vec3 = glm::vec3", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::Mat4", "kind": "alias", "header": "src/laige-render/include/laige/render/matrices.h", "line": 132, "signature": "using Mat4 = glm::mat4", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index e1f0cf4..f2f6e45 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -128,7 +128,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R iso_depth_table` green; baseline recorded in `docs/benchmarks/baselines/`. - **Size:** ~250 lines + tests -- [ ] **M2-ISO-03 · Isometric picking (screen → grid cell)** +- [x] **M2-ISO-03 · Isometric picking (screen → grid cell)** - **Refs:** FR-2.11 (O(1), exact at all zoom), AC-4.4; PRD §4 (click-to-select/move) - **Depends:** M2-ISO-01, M2-CAM-02 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 39b5128..1e265b6 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 10 | ⬜ in progress | +| M2 | 33 | 11 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **56** | | +| **Total** | **194** | **57** | | --- @@ -230,6 +230,7 @@ One line per completed (or split/renumbered) step. | 2026-10-01 | M2-ISO-02 | `—` | Depth-key budget workload fix — the CI `Linux x64 (clang++)` reference lane failed on the M2-CAM-01 merge (run 36885653175, job 110448173121): `IsoDepthTableBudget.TenThousandDirtyCells` measured mean **0.20889 ms** (fpx16_16) / **0.209044 ms** (fp32_pinned) against the 0.2 ms `iso_depthkey_rebuild` budget (methodology §5: the absolute gate is the CI reference machine — both P0 Linux lanes; the g++ lane passed, the clang lane missed by 4.5%); **root cause** (disassembly of the CMake-Debug `-O0` build) — the workload's measured `applyEdit` loop computed `i / 100`, `i % 100` and `(gx + gy) % 5` per call, and at `-O0` that is **three real `div`/`idiv` instructions per iteration** (~20-30 x86 cycles each, on top of the `setTile` call) — the gate was measuring the *harness's* division codegen, not the engine's 10 000 `setTile` calls; the `m2-iso-depth-table.md` baseline's "34% inside the gate on clang" (0.149184 ms) was recorded on the local machine with **Clang 22.1.8**, while the CI lane uses the ubuntu-24.04 runner's apt **Clang 18.1.3** on a slower shared runner (~1.40× the local number: 0.2089/0.149184 — the first crossing of this gate on that exact CI toolchain/runner), so the margin never existed on the gate platform; **fix** (test-only, no engine/API/budget change) — the 10 000 `(tileX, tileY, height)` edit triples are now **precomputed once outside every measured window** (the warm-up and the measured runs start below the precomputation) and the measured loop iterates the precomputed sequence — a **byte-identical `setTile` call sequence** (same order, same arguments) with **zero integer division in the measured loop** (disassembly-verified: 0 `div`/`idiv`; the only per-call calls are `setTile` and the caller's `Status::ok()` check) — this removes a harness artifact from the measured window, it does not relax the 0.2 ms PRD target or change the workload's engine work (TEST-010: no regression was hidden — no engine behavior or workload definition changed); **before/after** (CMake Debug, -O0, both backends, n=3000, warmup=100): canonical g++ 16.2.1 0.087398 → **0.0814067** ms (worse backend), local clang 22.1.8 0.149184 → **0.138931** ms (worse backend); the local deltas (−7%) are smaller than the CI artifact because this machine's out-of-order core overlaps `div` latency — on the CI runner the divisions were critical-path, which is why only the CI lane failed; **budgets** — `budgets.json` `measured` 0.087398 → **0.0814067** (worse of backends, canonical tree, the M2-ISO-02 recording convention); NEW baseline file `docs/benchmarks/baselines/m2-iso-depth-table-workload-fix.md` (sixth baseline; supersedes `m2-iso-depth-table.md` as the latest record, which stays immutable per methodology §4; the old baseline's numbers are the before/after pair) + `docs/benchmarks/baselines/README.md` index entry; **verification** — all six canonical trees build warning-free and ctest green (`build` 102/102, `build-clang` 102/102, `build-shared` 102/102, `build-release` 91/91, `build-asan` 99/99, `build-tsan` 99/99 — the sanitizer lanes run the fixed workload ungated: leak-free ASan, race-free TSan); `ctest -R iso_depth_table` green on the canonical tree (0.53 s); the CI `linux-clang` lane of this PR's run is the definitive gate-platform check; **compat** — test-only: no API, no engine code, no budgets.json `target`, no behavior change; Progress Board unchanged (M2 8/33, total 55/194) | | 2026-10-01 | M2-PROJ-01 | `—` | Projection modes + screen↔world transforms (M2-PROJ-01 scope, nothing else): **API** — new public header `src/laige-render/include/laige/render/projection.h` + implementation `projection.cpp` (`laige::render`, additive; built on the M2-GL-03 builders and the M2-CAM-01 camera — no new matrix code): `ProjectionMode` (`Iso`/`SideView`/`TopDown`/`FreeCinematic` — `Iso` is the engine default, ADR 0005), `Plane {normal, d}` (n·p = d; the normal need not be unit), `WorldRay {origin, direction}` (unit direction), and `ProjectionView` (a plain value object — the mode, the world→NDC `matrix` (the output of the mode's documented builder), and `planeCenter` (the side_view/top_down reference plane for the ray origins)): `worldToScreen(p2d, depth)` — the 2.5D world point (ground `p2d` + elevation `depth`) → NDC, one homogeneous 4×4 multiply (w = 1 exactly for the affine modes; NDC-z = 0 exactly for iso), `screenToWorldRay(ndc)` — the per-mode preimage: the FULL LINE for the affine modes (iso origin on the ground plane z = 0 via the invertible 2×2 ground solve; side_view/top_down origin on the plane through `planeCenter`) and a true RAY for free_cinematic (origin on the NDC near plane, direction = the normalized near→far preimage, away from the camera); the direction is `normalize(r1 × r2)` — the cross of the matrix's screen-x/screen-y rows — in all modes, unit length, `screenToWorld(ndc, plane)` — preimage ∩ plane → `Result` (a failed pick is a recoverable game condition, API-008): `InvalidArgument` when the plane is numerically parallel to the ray (`|dot(direction, n)| <= kProjectionParallelEps`) or, free_cinematic only, when the intersection is behind the NDC near plane (t < 0 — clipped, unpickable: points between the camera and the near plane are never rendered); named constants `kProjectionRoundTripTolerance = 1e-3f` (the documented round-trip bound for |p| ≤ 32) and `kProjectionParallelEps = 1e-6f` (CORE-005); the NDC↔pixel bridge is documented caller-side (RENDER-006: the window size is the GlContext's, not this pure API's); **semantics** — presentation-only (ARCH-009): reads nothing from and writes nothing to sim state; deterministic pure float math (no RNG, no clock, no allocation, O(1) — a 4×4 inverse ≈ 40 flops, called once per query); a per-pick query, NOT per-sprite work (the M2-SPRITE-02 batcher consumes `view.matrix` directly); **tests** — `tests/laige-render/projection_tests.cpp` (19 tests / 11 suites; CTest entry `projection` = the step's Verify command, TSan `halt_on_error=1`, TIMEOUT 60): `ProjectionDefaults` (mode/matrix defaults — `Iso`, identity, origin reference plane), `ProjectionIsoGoldens` (hand-computed world→NDC goldens — the 2:1 dimetric (1,0,0)→(2,−1,0) family with NDC-z = 0 exactly, true 30°/60°, and a custom invertible-but-not-depth-key-supported shear: the transforms still work, the key order is simply not guaranteed), `ProjectionIsoRay` (preimage origin on the ground plane exactly at the 2×2 solve; direction (−1,−1,−2)/√6 — away from the overhead viewer; screen-point-independent), `ProjectionIsoRoundTrip` (10k random points on plane z = p.z, `TestPrng(2101)`, within `kProjectionRoundTripTolerance`), `ProjectionSideViewGoldens` / `ProjectionSideViewRoundTrip` (plane camera y = 7: NDC-z golden −79/99 for (5,17,2); ray origin (5,7,2) direction (0,−1,0); 10k round trips, stream 2102), `ProjectionTopDownGoldens` / `ProjectionTopDownRoundTrip` (plane camera z = 0: NDC-z golden −197/99 for (5,7,49); ray direction (0,0,1); 10k round trips, stream 2103), `ProjectionCinematicOrtho` (ortho camera at (0,0,10) looking at the origin: NDC-z golden −81/99; ray origin (0,0,9) direction (0,0,−1); 10k random round trips in the visible volume z_cam ≤ −zNear, stream 2104), `ProjectionCinematicPerspective` (perspective camera at (0,0,20), fovY 60°, aspect 16/9: ray origin (0,0,19) direction (0,0,−1); 10k random round trips + a far outside-the-frustum point, stream 2105), `ProjectionErrors` (parallel planes per mode → `InvalidArgument`; zero normal → `InvalidArgument`; intersection behind the near plane (plane z = 20) → `InvalidArgument`, boundary t = 0 (plane z = 9) hits exactly at (0,0,9)); **AC-4.2** — the include-graph lint rule: R2 (arrows only downward, PRD §10.1) blocks every `laige-sim` → `laige-render` include; new fixture + CTest entry `include-lint-sim-to-render` (exit 1, asserting the R2 fragment for `laige/render/projection.h`) in `tests/tools/CMakeLists.txt` — the companion SURFACE check (no `ProjectionMode` in the laige-sim/laige-net public surface) lands with M2-AC-01; **docs** (DOC-006, same change) — NEW `docs/api/projection.md` (the full API contract: the mode table, the API table, the round-trip precision, the NDC↔pixel boundary, ownership/threading/phases, the Performance section, the misuse warnings, a performant example), `docs/README.md` link, `docs/concepts/coordinates.md` conversion-table rows updated (Screen → world: shipped M2-PROJ-01; World → screen: the transform shipped) + the AC-4.2 bullet (lint landed with M2-PROJ-01, surface check with M2-AC-01); **API manifest** — `laige-api.json` regenerated (`cmake --build build --target laige-api` — 1050 symbols / 32 headers; `api-real-tree` green); **verification** — all six canonical trees build warning-free and ctest green (`build` 104/104, `build-clang` 104/104, `build-release` 104/104, `build-shared` 104/104, `build-asan` 101/101, `build-tsan` 101/101 — leak-free ASan / race-free TSan, including the `projection` entry under `TSAN_OPTIONS=halt_on_error=1`); `ctest -R projection` green on the canonical tree (19 tests, <0.1 s); the CI lanes of this PR's run are the definitive gate-platform checks; **compat** — additive only: no existing API changed, no sim/net state touched (ARCH-009), no budgets.json entry (per-pick query — no budget gate for this step), no behavior change; Progress Board M2 9/33, total 56/194 | | 2026-10-02 | M2-CAM-02 | `—` | Isometric camera presets + grid-snap mode (M2-CAM-02 scope, nothing else): **API** — new public header `src/laige-render/include/laige/render/iso_camera.h` + implementation `iso_camera.cpp` (`laige::render`, additive; built on the M2-CAM-01 camera (owned by value) and the M2-GL-03 builders — no new matrix code): `IsoPresetKind` (`Dimetric2To1` — the engine default per ADR 0005 / `TrueIso3060` / `CustomShear`), `IsoPreset` (the SINGLE preset config value: kind + NDC `scale` for the built-in presets, `IsoAxes` for custom shear), `GridSnapOptions` (`enabled` + `gridSize` world units per cell), `IsoCameraOptions` (camera + preset + snap), named constants `kIsoSnapSqrtTwo = 1.4142135623730951f`, `kIsoSnapMarginPerCell = kIsoSnapSqrtTwo · 0.5f` (the snap's worst-case Euclidean movement per cell, g·√2/2), `kIsoSnapMinGridSize = 1e-6f` (CORE-005), and `IsoCamera` (copyable value object — no resources, no heap storage, zero allocation structural on every operation): `create(options)` (first-failure-wins validation → `InvalidArgument` + one rate-limited Warn `iso_camera/options_invalid` with the stable `option` field in the order `preset_kind` → `preset_scale` → `preset_shear` → `grid_size` → `bounds_grid_alignment` → `snap_margin`; the M2-CAM-01 camera options validate FIRST via `Camera::create` — its own `camera/options_invalid` warn, its failure short-circuits; the initial position is snapped into the grid in snap mode), state accessors (`camera()` — const, all mutation goes through the snap-aware mutators; `preset()`, `gridSnapEnabled()`, `gridSize()`), `matrix()` — the COMBINED world→NDC affine matrix of the current state (the preset's `isoMatrix(axes)` scaled by 1/zoom, translation = −axes(effective-eye ground)/zoom: at zoom 1 and no shake it equals the preset's `isoMatrix()` exactly — the −0.0 normalizations pinned; the camera's ground point projects to the NDC origin — the screen center — at every zoom/position; NDC-z is 0 for every world point — depth is engine-owned, PRD §4; the shake's z component does not enter the matrix — the iso projection has no vertical viewpoint), mutation (`setPosition` — (x,y) clamped into the bounds rectangle then snapped to the grid, the candidate validated against the INFLATED look-at margin before commit — state unchanged on rejection; z never snapped; `setTarget` — free (the grid locks the position, not the look-at point), validated against the inflated margin; `setFollowTarget`/`stopFollowing` — the M2-CAM-01 follow, in snap mode the position is snapped after every follow step; `setZoom` — snap mode: snapped to the documented dyadic ladder, no snap: the M2-CAM-01 clamp; `applyShake` — the M2-CAM-01 bounded shake, moves the view center (the matrix's translation)), `update()` (the M2-CAM-01 update — follow step → bounds clamp → shake decay — + the grid snap of the position, snap mode), and the static pure snap functions `snapCoord(v, g)` (nearest grid multiple, ties away from zero, FLOAT-ONLY arithmetic — no integer conversion, total for every finite v and g > 0) and `snapZoomLevel(z, zoomMin, zoomMax)` (clamps into the bounds, nearest level in log2 space, exact float tie to the HIGHER zoom, always an exact ladder member — idempotent); **semantics** — the DOCUMENTED snap-mode choice (the roadmap leaves the choice to this step): CONTINUOUS snap — on create, on every `setPosition`, and after every follow step of `update()` (the standard isometric grid-locked feel: the camera never rests off the grid, tracks moving targets in grid steps even mid-follow; snap-on-release was considered and rejected — between releases the camera slides off-grid, which is not the isometric look); the zoom levels are the documented DYADIC LADDER `L = {zoomMin·2ⁿ : 0 ≤ zoomMin·2ⁿ ≤ zoomMax}` — power-of-two spacing from `zoomMin` so the grid-to-screen scale is a power of two times the level-0 scale at every level (halving/doubling the scale preserves pixel alignment; an arbitrary factor cannot) — the final pixel alignment is window-size-dependent (the game's concern, RENDER-006); both invariants are TOTAL inside the documented world domain (|x|, |y| ≤ 32767, coordinates.md §1) by the INFLATED look-at margin `|target − position| > maxShakeOffset + g·√2/2` (the snap moves the position by at most g/2 per ground axis — no snap can break the M2-CAM-01 margin; no input exists for which a snap is silently skipped); grid-snap + bounds REQUIRES the bounds rectangle to be grid-aligned (every corner an exact multiple of `gridSize` — validated at create: the nearest grid multiple of a value in a grid-aligned interval lies in it); a mutation whose SNAPPED candidate violates the margin is rejected, state unchanged (the documented failure path for adversarial inputs); a non-representable snap result (outside the domain for the grid size) is rejected with a `snap_not_representable` warn — state stays valid, off-grid for that frame only; **custom-shear validation** (M2-CAM-02 owns the scene-shear gate per coordinates.md §4.4 / iso_depth_key.h): the CustomShear axes must pass the M2-ISO-01 `isoShearSupported()` (both ground axes project downward with equal slope `A = −dx.y = −dy.y = zUnit`, invertible ground map) — a shear that does not sort back-to-front would break the engine-owned depth order (RENDER-003), so unsupported shears are REJECTED at the config boundary; the raw `isoMatrix()`/`ProjectionView` path is unchanged (an unsupported-but-invertible shear still transforms — the M2-PROJ-01 contract — it is simply not a scene preset); **ARCH-009** — presentation-only: reads/writes nothing to sim state (the follow target comes from the presentation state, M1-LOOP-02's read-only boundary), state never part of replay state or the sim state hash; determinism scope: bit-identical state for the same input sequence on the same build/platform (fixed float op sequence — render-side float, NOT SimMath — the ADR 0002 pinned-math contract does not apply; no RNG, no clock); single owner (the render set-up phase), not thread-safe (CONC-001); **tests** — `tests/laige-render/iso_camera_tests.cpp` (24 tests / 5 suites; CTest entry `iso_camera` = the step's Verify command, unquoted `--gtest_filter IsoCamera*`, TSan `halt_on_error=1`, TIMEOUT 60): `IsoCameraCreate` (defaults are 2:1 dimetric with default matrix == `isoDimetric2To1(1)` exactly; the preset matrices == the M2-GL-03 builders exactly (dimetric/true-iso at scale 2, supported custom shear == `isoMatrix(axes)`); the zoom-2 matrix golden, the camera-center → NDC origin, the hand-computed world point (5,7,1) → (−2,−6,0), the shake moves the translation (the z component ignored); 8 rejection groups with exact warn counts + `option` fields: unknown kind, scale ≤ 0/NaN, three unsupported shears (slope mismatch, zUnit ≠ A, degenerate det), grid size 0/−1/NaN/1e-9, non-aligned bounds (the same rectangle accepted at g = 0.5), the tight snap margin, the base-camera failure short-circuiting with `camera/options_invalid` and zero `iso_camera` events), `IsoCameraGridSnap` (the create snaps the initial position (0.3,0.8,0) → (0,1,0); the `snapCoord` oracle incl. the ±3 → ±4 tie cases and idempotence; a 2000-iteration seeded adversarial mix (`TestPrng(0x49534F43)`): setPosition/setZoom/applyShake/follow/update — on-grid after EVERY operation and every update; the far-follow into the aligned bounds [−10,10]×[−12,12] at g = 2: 2000 frames on-grid AND inside the rectangle; z never snapped (the rigid follow converges z to 2.73 — an off-grid height the snap leaves untouched); the snapped-candidate margin violation rejected, state unchanged + warn; `setTarget` uses the inflated margin (0.5 rejected under snap, accepted without); non-finite inputs rejected on all three snap-aware setters, state unchanged), `IsoCameraZoomLevels` (the documented set for (0.25, 2) = {0.25, 0.5, 1, 2}: every level a fixed point, below/above clamp to the bounds, the midpoint 0.25·√2 just-below → 0.25 / exact float tie → the higher 0.5 / just-above → 0.5; the non-dyadic top bound (1, 10): L = {1,2,4,8}, snap(9) = snap(10) = 8, snap(4.5) = 4, snap(5.7) = 8; degenerate hi == lo; 10k random zooms: the result always an exact ladder member + idempotent; `setZoom` integration: 1.2 → 1, 1.5 → 2, out-of-range clamps; the no-snap camera keeps 1.2 exactly), `IsoCameraDeterminism` (a 200-step identical input sequence on two cameras, non-dyadic grid 0.7: bit-identical position/zoom/matrix at every step), `IsoCameraStopped` (valid false, identity matrix, InvalidArgument mutators, zero log events); **docs** (DOC-007, same change) — NEW `docs/api/iso_camera.md` (the full API contract: the presets, the matrix formula, the snap mode + invariants + the documented continuous choice, the zoom ladder, the API table, the option-validation order, ownership/threading/determinism, the logging table, the DOC-004 Performance section, a performant example, the misuse warnings) + `docs/api/camera.md` (the five additive const accessors in the API table; "land in M2-CAM-02" → now shipped, link) + `docs/api/projection.md` (the scene-shear validation is the `IsoCamera` preset config; Related link) + `docs/concepts/coordinates.md` (the preset selection + the shear checker are shipped; the World→screen row shipped-to-NDC) + `docs/README.md` index + `src/laige-render/README.md` status; **API manifest** — `laige-api.json` regenerated (`cmake --build build --target laige-api` — 1093 symbols / 33 headers; `api-real-tree` green); **local verification** — all six canonical trees build warning-free and ctest green: 105/105 `build` (Debug g++ 16.2.1), 105/105 `build-clang` (clang++ 22.1.8), 105/105 `build-shared`, 94/94 `build-release` (the debug-only entries don't register — the M2-GL-01 convention), 102/102 `build-asan`, 102/102 `build-tsan` — leak-free ASan / race-free TSan, including the `iso_camera` entry under `TSAN_OPTIONS=halt_on_error=1`; `ctest -R iso_camera` green on the canonical tree (24 tests, <0.1 s); `tools/laige-include-lint` OK (61 source files — the new header adds no module edge: render includes render + core only), `tools/laige-determinism-lint` OK; **compat** — additive only: no existing symbol or behavior changed (five additive const accessors on `Camera`); no sim/net state touched (ARCH-009); no budgets.json entry (per-frame O(1) presentation state); **scope note** — the implementation exceeds the roadmap's "~200 lines" sanity note for the same documented-contract reason as M2-CAM-01/M2-ISO-01 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); **CI follow-up** (run 37006646480, PR #67) — the `linux-gcc` lane (and the `Determinism check` job, same build) failed: the CI g++'s libstdc++ does not expose `std::floorf`/`std::ceilf` in the `std` namespace (the clang/ASan/TSan lanes passed the same code) — fixed on the same branch: the snap's rounding now uses the C global-namespace `::floorf`/`::ceilf` (bit-identical float semantics — no double promotion; the global forms are the portable ``/`` guarantee), with an exact-site comment citing the failing run; all six local trees re-verified after the fix; Progress Board M2 10/33, total 56/194 (the total also reconciles the board with the milestone sum — M2-PROJ-01's row reported 56 while the board stayed at 55) | +| 2026-10-02 | M2-ISO-03 | `—` | Isometric picking — screen → ground → grid cell (M2-ISO-03 scope, nothing else): **API** — header-only `src/laige-render/include/laige/render/iso_picking.h` (`laige::render`, public, additive): `IsoGridConfig` (the pick grid: `cellSize` world units per cell; the tile map's grid is 1.0), `IsoGridPick` (the cell indices `cellX`/`cellY` + the computed `ground` point), and `screenToGrid(screen, camera, grid)` — the O(1) inverse of the M2-CAM-02 camera matrix (a 2×2 solve on the ground rows — no per-pick 4×4 inverse, no GLM, no allocation) plus a `ProjectionView` overload for hand-stored iso matrices (the M2-PROJ-01 base the step lands on; precondition `mode == Iso`); **boundary rule** — cell `(gx, gy)` is the half-open square `[gx·g, (gx+1)·g) × [gy·g, (gy+1)·g)` (`floor`; exact boundary → the forward cell, corner → the upper-right cell), the grid anchored at the world origin (the tile map's tile (gx, gy) at g = 1, center (gx+0.5, gy+0.5) — the grid the M2-CAM-02 grid-snap camera locks to); **exact at all supported zoom** — the inverse is a fixed float sequence, zoom enters only through the matrix's entries, so a stored screen point resolves to the same cell at every zoom in the camera's supported set (continuous range or snap ladder); **precision** — `|ŵ − w| ≤ 16·2⁻²⁴·κ·(|e| + |w|)` world units (κ = the ∞-norm condition number of the ground 2×2: 4.5 for 2:1, ~2.73 for 30/60; a point resolves to its exact cell unless within that bound of a boundary — ~6e-4 at scene scale, `kIsoPickDomainBoundaryEps` ≈ 4.0 in the domain worst case at κ ≤ 64, |e| = |w| = 32767 — either adjacent cell inside the zone, deterministic per build); **total function** — non-finite screen saturates at ±32767 (NaN → lower bound — the isoDepthKey convention), result cell fits int32 for every valid g (`kIsoPickMinCellSize = 1e-4`), a stopped camera picks with the identity matrix (degenerate but total); no error path, no per-call assert (a failed click is not an engine failure); **tests** — `tests/laige-render/iso_picking_tests.cpp` (9 tests / 8 suites; CTest entry `iso_picking` = the step's Verify command, TSan `halt_on_error=1`, TIMEOUT 60): `IsoPickGolden` (hand-computed cells at 4 zoom levels for 2:1 dimetric — (3,2)/(-2,-1)/(0,0) at Z ∈ {0.25, 1, 4, 16} — plus a camera-offset case e = (5,−3), and true 30/60 at 2 zooms), `IsoPickProperty` (the roadmap property: 4096 cells × 4 zooms = 16 384 picks ≥ 10k — `screen_to_grid(world_to_screen(cell_center)) == cell` + the ground within 1e-3 + the zero-allocation proof: 1000 consecutive picks = 0 heap blocks under the allocation watch), `IsoPickBoundary` (the half-open rule at 0.05 off the boundaries — exact sides — + the exact-boundary/corner cases pinning the documented zone membership + the g = 2 scaling), `IsoPickCustomShear` (a supported shear dx = (2,−1)/dy = (−1,−1)/zUnit = 1: hand-computed cell + 256-cell property), `IsoPickNonFinite` (NaN/±inf → the documented saturation, incl. the g = 2 case 32767/2 → 16383), `IsoPickStoppedCamera` (the identity degenerate pick), `IsoPickView` (the overload parity — same matrix → bit-identical pick), `IsoPickBudget` (the PRD §8.1 `iso_picking` gate — 3000 measured picks over precomputed NDC points, mean ≤ 0.01 ms, gated on Linux non-instrumented trees — `LAIGE_ISO_PICK_BUDGET`); **budget** — the `iso_picking` entry's `measured` retires the M0 convention: **0.000136262 ms mean** (canonical Debug g++ -O0, n = 3000; 73× inside the 0.01 ms gate; the cross-compiler clang -O0 run 0.000189456 ms — 53× inside) — baseline `docs/benchmarks/baselines/m2-iso-picking.md` (the sixth baseline file); **docs** — `docs/api/iso_picking.md` (the full contract: the API, the inverse, the boundary rule + precision, the total-function/saturation section, ownership/threading/determinism, Performance, misuse warnings), `docs/concepts/coordinates.md` §5.1 (the canonical home: the inverse formula, the half-open boundary rule, the precision zone, the budget), `docs/README.md` API index, `src/laige-render/README.md` status; `laige-api.json` regenerated (1106 symbols / 34 headers — +13); `api-real-tree`/`api-check-fresh`, `include-lint` (61 files), and `determinism-lint` green; all six local trees verified (build, build-clang, build-release, build-shared, build-asan, build-tsan — `ctest -R iso_picking` + the full `laige-render_tests` green, no new warnings); **scope note** — the implementation exceeds the roadmap's "~150 lines" sanity note for the same documented-contract reason as M2-CAM-01/02 (the header preamble + the API doc are part of the implementation per CORE-006/DOC-004); Progress Board M2 11/33, total 57/194 | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index 9acdea1..0db252f 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -192,5 +192,30 @@ against the M2-GL-03 builders, the create validation, the on-the-grid invariant under 2k adversarial mutation/follow inputs, the zoom-level set exactly, and the bit-identical determinism replay). +M2-ISO-03 landed the isometric grid picking — the engine-owned, safe +screen → ground-plane → grid-cell transform (FR-2.11, PRD §4 +click-to-select/move; S-5/G-R11: game code never inverts the iso +matrix itself). Header-only public header `include/laige/render/ +iso_picking.h`: `IsoGridConfig` (the pick grid: `cellSize` world units +per cell), `IsoGridPick` (the cell indices + the computed ground +point), and `screenToGrid(screen, camera, grid)` — the O(1) inverse of +the M2-CAM-02 camera matrix (a 2×2 solve on the ground rows; no +per-pick 4×4 inverse, no allocation) with a `ProjectionView` overload +for hand-stored iso matrices (the M2-PROJ-01 base). The grid cells are +half-open (`[gx·g, (gx+1)·g)²`, the documented boundary rule); the +pick is exact at all supported zoom levels (a fixed float sequence — +zoom enters only through the matrix) with a documented precision zone +(`16·2⁻²⁴·κ·(|e| + |w|)` — ~6e-4 world units at scene scale); a +total function (non-finite screen saturates at ±32767; a stopped +camera picks with the identity matrix). Presentation-only, +deterministic per build, zero allocation. The PRD §8.1 `iso_picking` +budget (one pick mean ≤ 0.01 ms) is gated by the `iso_picking` ctest +entry (measured 0.000136262 ms — the sixth baseline, +[docs/benchmarks/baselines/m2-iso-picking.md](../docs/benchmarks/baselines/m2-iso-picking.md)). +API contract in +[docs/api/iso_picking.md](../docs/api/iso_picking.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `iso_picking` +— pure float math, no GL environment required). + The sprite batcher, draw submits, and sim→render wiring land in the remaining M2 steps (M2-SPRITE-01/02 on). diff --git a/src/laige-render/include/laige/render/iso_picking.h b/src/laige-render/include/laige/render/iso_picking.h new file mode 100644 index 0000000..003ebcd --- /dev/null +++ b/src/laige-render/include/laige/render/iso_picking.h @@ -0,0 +1,311 @@ +// laige-render isometric grid picking (M2-ISO-03): the engine-owned, +// safe screen -> ground-plane -> grid-cell transform. +// +// FR-2.11 (roadmap/M2-rendering-2.5d.md, M2-ISO-03 scope): the +// isometric picking is O(1), exact at all supported zoom levels, and +// returns an integer grid cell — the engine owns the inverse (S-5, +// G-R11: game code never writes its own screen->grid / z-ordering +// math). M2-PROJ-01: the screen<->world transforms are the base this +// step lands on (projection.h: "the isometric grid picking lands on +// top of this in M2-ISO-03"). PRD §4: click-to-select / click-to- +// move is a first-class isometric need. +// +// IsoGridConfig the pick grid: the cell size in world units +// IsoGridPick the pick result: the grid cell + the ground point +// screenToGrid() the O(1) inverse: NDC screen -> ground -> cell, +// for the M2-CAM-02 IsoCamera or an Iso +// ProjectionView (the M2-PROJ-01 base) +// +// --------------------------------------------------------------------------- +// The inverse (O(1), FR-2.11) +// --------------------------------------------------------------------------- +// +// The frame's render matrix M (the IsoCamera::matrix() of the camera's +// CURRENT state — the M2-CAM-02 matrix section — or an Iso +// ProjectionView's matrix) maps a ground-plane point (x, y, 0) to +// NDC: +// +// ndc.x = a*x + b*y + tx a = m[0][0], b = m[1][0], tx = m[3][0] +// ndc.y = c*x + d*y + ty c = m[0][1], d = m[1][1], ty = m[3][1] +// +// (column-major m[c][r]; row 2 is 0 and row 3 = (0, 0, 0, 1) for every +// iso matrix — the matrices.h contract). The inverse is the 2x2 solve +// — one pick is a few flops: no per-pick 4x4 inverse, no GLM calls, +// no allocation: +// +// det = a*d - b*c (the ground map's det) +// w.x = (d*(ndc.x - tx) - b*(ndc.y - ty)) / det +// w.y = (a*(ndc.y - ty) - c*(ndc.x - tx)) / det +// +// det != 0 is the isoMatrix / IsoCamera precondition (the ground map +// is invertible — matrices.h). The solve is the exact inverse of the +// STORED float matrix: the pick resolves to the cell of the +// projection the frame actually renders with — never a different, +// "ideal" matrix. +// +// The grid cell (the documented BOUNDARY RULE, FR-2.11): cell (gx, gy) +// is the half-open square +// +// [gx*g, (gx+1)*g) x [gy*g, (gy+1)*g) +// +// i.e. gx = floor(w.x / g), gy = floor(w.y / g). A point exactly on a +// cell's lower or left boundary belongs to THAT cell; a point exactly +// on its upper or right boundary belongs to the cell beyond it (the +// floor convention); the corner of four cells belongs to the cell to +// its upper right. The grid is anchored at the world origin: the tile +// map's tile (gx, gy) (M2-TILE-01) is exactly this cell at g = 1, with +// center (gx + 0.5, gy + 0.5) — the grid the M2-CAM-02 grid-snap +// camera locks to. +// +// EXACT AT ALL SUPPORTED ZOOM LEVELS: the inverse is a fixed sequence +// of float ops with no zoom-dependent branch or table — zoom enters +// only through M's entries (the M2-CAM-02 matrix build), so the pick +// of a stored screen point resolves to that point's exact cell at +// every zoom the camera supports (the continuous [zoomMin, zoomMax] +// range without snap, the dyadic ladder with snap — both are +// "supported zoom" here). The goldens pin 4 zoom levels by hand. +// +// Precision (the documented BOUNDARY ZONE): the computed ground point +// w-hat satisfies +// +// |w-hat - w| <= kIsoPickOpFactor * kIsoPickUlp * kappa * (|e| + |w|) +// +// (world units, per axis), where w is the exact ground point of the +// exact inverse, e is the camera's ground point +// (effectivePosition() = position + shakeOffset), and kappa is the +// infinity-norm condition number of the UNSCALED ground 2x2 [[dx.x, +// dy.x], [dx.y, dy.y]] (the built-in presets: kappa = 4.5 for 2:1 +// dimetric, ~2.73 for true 30/60 — scale-invariant, so it is a +// property of the preset, not of the zoom). kIsoPickOpFactor = 16 +// covers the 10 rounding ops of the fixed sequence (2x2 solve + cell +// divisions) with margin over the backward-error bound 4*kappa*u* +// (|w| + |w - e|). Consequence (the boundary guarantee): the pick +// returns the exact cell of the exact inverse UNLESS the exact ground +// point sits within that bound of a cell boundary; inside that zone +// either of the two adjacent cells may be returned (the float +// rounding decides — deterministic per build, never a random flip +// between frames of the same stored matrix). The zone is ~6e-4 world +// units at scene scale (|e|, |w| <= 64.5, a built-in preset) and +// kIsoPickDomainBoundaryEps world units in the documented domain +// worst case (kappa <= kIsoPickMaxCondition = 64 — 14x the built-ins +// — and |e| = |w| = 32767). A custom shear with kappa > 64 still +// picks (the inverse is total over every invertible shear) but its +// zone scales linearly in kappa. +// +// --------------------------------------------------------------------------- +// Total function, domain, and saturation (CORE-008) +// --------------------------------------------------------------------------- +// +// screenToGrid is TOTAL: it returns a pick for EVERY input — no error +// path, no per-call assert (a per-click pick is called from input +// handling with untrusted data; a failed click must not be an engine +// failure the game handles — the isoDepthKey total-function +// precedent): +// +// - non-finite screen components propagate through the fixed float +// sequence and the ground point saturates at the documented world +// domain (kIsoDepthMaxWorldUnits = 32767, iso_depth_key.h): +// +inf -> +bound, -inf and NaN -> -bound (IEEE: NaN compares +// false — the isoDepthKey saturation convention); +// - the result cell is then within |gx|, |gy| <= 32767/g — inside +// int32 for every valid g (g >= kIsoPickMinCellSize: +// 32767 / 1e-4 = 3.3e8 < 2^31 - 1). +// +// The grid config is a config value validated by the game at +// construction (the preset-scale precedent): finite, > 0, and +// >= kIsoPickMinCellSize is a precondition (the matrices.h house +// convention: a violation is a programmer error — debug assert, +// release undefined behavior). +// +// A STOPPED (invalid) IsoCamera picks with the identity matrix (the +// M2-CAM-02 stopped-state contract: matrix() is the identity): +// cell = floor(screen / g) — degenerate but total; the game checks +// valid() before relying on the pick. +// +// --------------------------------------------------------------------------- +// Safe API (S-5, G-R11) +// --------------------------------------------------------------------------- +// +// This is the engine-owned screen->grid path: game code passes a +// screen point (NDC — the documented pixel <-> NDC conversion of +// projection.h is the input boundary, RENDER-006) and gets the grid +// cell; it never inverts the iso matrix itself. The result's `ground` +// is the computed ground-plane point (world units) for proximity +// queries and diagnostics; the cell is the pick. +// +// The grid PLANE is the ground plane (z = 0): a click on raised +// terrain picks the cell of its ground projection (the standard +// isometric click-to-select semantics). The tile-height-aware pick +// (pick the standing cell of the clicked tile) lands with the tile +// map (M2-TILE-01/02), which consumes this inverse. +// +// --------------------------------------------------------------------------- +// Determinism, threading, performance (PERF-002/003, DOC-004) +// --------------------------------------------------------------------------- +// +// Pure function of (screen, the camera's current matrix, grid): +// deterministic per build — a fixed sequence of float ops (render- +// side float, NOT SimMath — the pinned-math contract of ADR 0002 +// does not apply; no RNG, no clock). Presentation-only (ARCH-009): +// reads no sim state, writes nothing; never part of replay state or +// the simulation state hash. +// +// O(1): 10 rounding ops + 2 exact floorfs + the camera matrix build +// (O(1) itself — the pick is a per-click / per-query call in input +// handling, NEVER per sprite or per frame). No allocation, no +// logging, no GL calls — disabled cost is zero. Budget: the +// `iso_picking` entry of budgets.json (one pick, mean <= 0.01 ms, +// PRD §8.1) — the gated budget suite +// (tests/laige-render/iso_picking_tests.cpp) records the baseline in +// docs/benchmarks/baselines/m2-iso-picking.md. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - The screen is NDC (projection.h), not window pixels: convert +// with the documented one-liner first (RENDER-006 — the window is +// a platform detail). +// - The camera's matrix() is rebuilt on the pick (O(1)): it always +// reflects the camera's CURRENT state — do not cache the matrix +// across camera changes and pick with the stale one. +// - Do not pick through a stopped camera (check valid() first) or a +// non-Iso ProjectionView (the overload's precondition). +// - The cell is on the GROUND plane (z = 0): for raised terrain the +// cell is the ground projection — the tile map step documents the +// height-aware variant. + +#pragma once + +#include +#include +#include + +#include "laige/render/iso_camera.h" // IsoCamera (M2-CAM-02) +#include "laige/render/iso_depth_key.h" // kIsoDepthMaxWorldUnits (M2-ISO-01) +#include "laige/render/projection.h" // ProjectionView (M2-PROJ-01) + +namespace laige::render { + +// The float rounding unit (2^-24): the per-op relative error bound of +// one IEEE-754 binary32 op (CORE-005; the precision formula below). +inline constexpr float kIsoPickUlp = 5.9604644775390625e-8f; +// The rounding-op count of the picking inverse's fixed sequence (10 +// rounding ops: the 2x2 solve + the two cell divisions) plus margin +// (CORE-005; the precision formula below). +inline constexpr float kIsoPickOpFactor = 16.0f; +// The precision contract's condition-number bound (the header +// preamble): the built-in presets are kappa = 4.5 (2:1) / ~2.73 +// (30/60); a custom shear with kappa <= 64 keeps the documented +// boundary zone, and beyond it the zone scales linearly in kappa. +inline constexpr float kIsoPickMaxCondition = 64.0f; +// The domain-worst boundary ambiguity zone (the header preamble): +// kIsoPickOpFactor * kIsoPickUlp * kIsoPickMaxCondition * +// (2 * kIsoDepthMaxWorldUnits) world units (~4.000061). +inline constexpr float kIsoPickDomainBoundaryEps = + kIsoPickOpFactor * kIsoPickUlp * kIsoPickMaxCondition * + (2.0f * static_cast(kIsoDepthMaxWorldUnits)); +// The minimum grid cell size (world units): below it the saturated +// cell index 32767/g can leave int32 (CORE-005: 32767/1e-4 = 3.3e8 < +// 2^31 - 1 with 6.5x margin). +inline constexpr float kIsoPickMinCellSize = 1e-4f; + +// The pick grid (FR-2.11's grid_config): the cell size in world units +// (finite, > 0, >= kIsoPickMinCellSize — a config precondition, the +// header preamble). 1.0 world unit per cell is the tile map's grid +// (M2-TILE-01). +struct IsoGridConfig { + float cellSize{1.0f}; +}; + +// The pick result (the safe API — S-5/G-R11): the grid cell (the +// floor convention, the header preamble) + the computed ground-plane +// point (world units; the diagnostics / proximity-query view of the +// pick). A plain value — no ownership, nothing to release. +struct IsoGridPick { + std::int32_t cellX{}; // the cell's x index (floor(w.x / cellSize)) + std::int32_t cellY{}; // the cell's y index (floor(w.y / cellSize)) + Vec2 ground{}; // the (saturated) ground-plane world point +}; + +namespace detail { + +// The 2x2 ground-plane inverse of the stored iso matrix (the header +// preamble's formula): the world (x, y) on z = 0 that projects to the +// NDC screen point. Non-finite inputs propagate to non-finite output +// (the caller saturates — the total-function contract). Precondition: +// the matrix is a valid iso matrix (det != 0 — the isoMatrix / +// IsoCamera contract). +[[nodiscard]] inline Vec2 groundInverse(Vec2 screen, const Mat4& m) + noexcept { + const float a = m[0][0], b = m[1][0], c = m[0][1], d = m[1][1]; + const float sx = screen.x - m[3][0]; + const float sy = screen.y - m[3][1]; + const float det = a * d - b * c; + return Vec2{(d * sx - b * sy) / det, (a * sy - c * sx) / det}; +} + +// The domain saturation (the isoDepthKey total-function convention): +// +inf -> +bound, -inf and NaN -> -bound (IEEE: NaN compares false in +// `v > 0.0f`). Finite values pass through unchanged. +inline float saturateWorldCoord(float v) noexcept { + if (std::isfinite(v)) return v; + return (v > 0.0f) ? static_cast(kIsoDepthMaxWorldUnits) + : -static_cast(kIsoDepthMaxWorldUnits); +} + +// The pick from a stored iso matrix: the 2x2 inverse + the domain +// saturation + the half-open cell (the header preamble). The ::floorf +// global forms (not std::floorf): some CI g++ toolchains expose the +// float versions only in the global namespace (the iso_camera.cpp +// precedent — PR #67 CI run 37006646480, linux-gcc lane). +[[nodiscard]] inline IsoGridPick pickFromMatrix(Vec2 screen, const Mat4& m, + float cellSize) noexcept { + Vec2 w = groundInverse(screen, m); + const float wx = saturateWorldCoord(w.x); + const float wy = saturateWorldCoord(w.y); + return IsoGridPick{static_cast(::floorf(wx / cellSize)), + static_cast(::floorf(wy / cellSize)), + Vec2{wx, wy}}; +} + +} // namespace detail + +// The safe isometric pick (FR-2.11; the header preamble is the full +// contract): the NDC screen point -> the ground plane (z = 0) -> the +// grid cell. O(1) (the 2x2 solve + two divisions + the camera's O(1) +// matrix build); total for every input (the header preamble's +// saturation section); no allocation, no logging, no GL. +// @budget O(1); no allocation. +[[nodiscard]] inline IsoGridPick screenToGrid(Vec2 screen, + const IsoCamera& camera, + const IsoGridConfig& grid) + noexcept { + const float g = grid.cellSize; + assert(std::isfinite(g) && g > 0.0f && g >= kIsoPickMinCellSize && + "IsoGridConfig: cellSize must be finite, > 0, and >= " + "kIsoPickMinCellSize"); + return detail::pickFromMatrix(screen, camera.matrix(), g); +} + +// The ProjectionView overload (the M2-PROJ-01 base this step lands on +// — the header preamble): the same inverse over a hand-stored iso +// matrix (advanced use: e.g. a custom shear through the raw +// isoMatrix builder, M2-CAM-02). Precondition: view.mode == +// ProjectionMode::Iso (the per-mode structure assumption — the +// matrices.h house convention: debug assert, release undefined). +// @budget O(1); no allocation. +[[nodiscard]] inline IsoGridPick screenToGrid(Vec2 screen, + const ProjectionView& view, + const IsoGridConfig& grid) + noexcept { + assert(view.mode == ProjectionMode::Iso && + "screenToGrid(ProjectionView): the view must be in Iso mode"); + const float g = grid.cellSize; + assert(std::isfinite(g) && g > 0.0f && g >= kIsoPickMinCellSize && + "IsoGridConfig: cellSize must be finite, > 0, and >= " + "kIsoPickMinCellSize"); + return detail::pickFromMatrix(screen, view.matrix, g); +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index e0fea22..25e98f9 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -43,7 +43,16 @@ # round trips against the documented precision per mode, and the # documented Result failure paths (parallel plane, zero normal, # intersection behind the camera)) — pure value math: no GL -# environment needed. +# environment needed; and the isometric grid picking (M2-ISO-03) — +# the engine-owned safe screen -> ground -> grid-cell transform +# (iso_picking_tests.cpp's IsoPick* suites: the hand-computed golden +# cells at 4 zoom levels for the 2:1 dimetric and true 30/60 presets, +# the 10k-cell x 4-zoom center round-trip property (plus the +# zero-allocation proof), the documented half-open boundary rule and +# boundary zone, the supported-shear round trip, the total-function +# saturation contract, the stopped-camera degenerate pick, the +# ProjectionView overload parity, and the PRD §8.1 one-pick budget +# gate) — pure float math: no GL environment needed. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -56,9 +65,10 @@ # M2-ISO-02 Verify command (`ctest -R iso_depth_table`), the `camera` # entry is the M2-CAM-01 Verify command (`ctest -R camera`), the # `iso_camera` entry is the M2-CAM-02 Verify command -# (`ctest -R iso_camera`), and the `projection` entry is the -# M2-PROJ-01 Verify command (`ctest -R projection`), each selecting -# exactly its suites from the shared executable. +# (`ctest -R iso_camera`), the `projection` entry is the M2-PROJ-01 +# Verify command (`ctest -R projection`), and the `iso_picking` entry +# is the M2-ISO-03 Verify command (`ctest -R iso_picking`), each +# selecting exactly its suites from the shared executable. # # Environment note: the GlContextSmoke and RenderThreadOffscreen suites # require a usable OpenGL 3.3 environment — always present on the P0 CI @@ -72,7 +82,7 @@ add_executable(laige-render_tests gl_context_tests.cpp render_thread_tests.cpp matrices_tests.cpp iso_depth_key_tests.cpp iso_depth_table_tests.cpp camera_tests.cpp - iso_camera_tests.cpp projection_tests.cpp) + iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp) laige_apply_engine_policy(laige-render_tests) target_link_libraries(laige-render_tests PRIVATE gtest_main laige-render) # The randomized suites draw through the test-only seed helper @@ -150,11 +160,26 @@ add_test(NAME iso_camera add_test(NAME projection COMMAND laige-render_tests --gtest_filter=Projection*) +# M2-ISO-03: the step's Verify command is `ctest -R iso_picking`. Pure +# float math (no GL calls) — runs in every local tree and in CI. The +# LAIGE_BUDGETS_PATH env var points the budget suite at the repo +# budgets.json. The budget gate compiles only on Linux in the +# non-instrumented trees (LAIGE_ISO_PICK_BUDGET — the +# iso_depth_table_tests.cpp precedent); other platforms run the budget +# WORKLOAD ungated. +add_test(NAME iso_picking + COMMAND laige-render_tests --gtest_filter=IsoPick*) +set_tests_properties(iso_picking PROPERTIES + ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") + if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUDGET=1) + target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_PICK_BUDGET=1) if(NOT CMAKE_BUILD_TYPE STREQUAL "") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUILD_TYPE="${CMAKE_BUILD_TYPE}") + target_compile_definitions(laige-render_tests PRIVATE + LAIGE_ISO_PICK_BUILD_TYPE="${CMAKE_BUILD_TYPE}") endif() endif() @@ -163,12 +188,13 @@ if(LAIGE_TSAN) # The M2-GL-02 `render_thread` entry is the step's TSan gate (the # lock-free handoff's race-freedom, the 3000-frame no-deadlock run); # the pure-math `matrices`, `iso_depth_key`, `iso_depth_table`, - # `camera`, `iso_camera`, and `projection` entries carry the option - # for uniformity (they have no shared state, but the flag is - # harmless). The test-level ENVIRONMENT replaces the job-level one, - # so the budgets path is restated here. + # `camera`, `iso_camera`, `projection`, and `iso_picking` entries + # carry the option for uniformity (they have no shared state, but + # the flag is harmless). The test-level ENVIRONMENT replaces the + # job-level one, so the budgets path is restated here. set_tests_properties(laige-render_tests gl_context render_thread matrices - iso_depth_key iso_depth_table camera iso_camera projection PROPERTIES + iso_depth_key iso_depth_table camera iso_camera projection + iso_picking PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -199,10 +225,10 @@ endif() # The offscreen smoke creates a real (software) context + FBO; give a # slow headless runner generous headroom. The pure-math `matrices`, -# `iso_depth_key`, `camera`, `iso_camera`, and `projection` entries are -# small (thousands of O(1) computations, comparisons, and O(1) camera / -# projection updates at most, plus 10k-point round trips) — 60s is -# generous headroom. +# `iso_depth_key`, `camera`, `iso_camera`, `projection`, and +# `iso_picking` entries are small (thousands of O(1) computations, +# comparisons, and O(1) camera / projection updates and picks at most, +# plus 10k-cell round trips) — 60s is generous headroom. set_tests_properties(laige-render_tests gl_context PROPERTIES TIMEOUT 180) set_tests_properties(render_thread PROPERTIES TIMEOUT 300) set_tests_properties(matrices PROPERTIES TIMEOUT 60) @@ -210,6 +236,7 @@ set_tests_properties(iso_depth_key PROPERTIES TIMEOUT 60) set_tests_properties(camera PROPERTIES TIMEOUT 60) set_tests_properties(iso_camera PROPERTIES TIMEOUT 60) set_tests_properties(projection PROPERTIES TIMEOUT 60) +set_tests_properties(iso_picking PROPERTIES TIMEOUT 60) # The `iso_depth_table` entry includes the budget gate: 2 backends x # (100 warm-up + 3000 measured) iterations of 10 000 setTile calls — # a few seconds on the canonical Debug tree; 120s is generous headroom diff --git a/tests/laige-render/iso_picking_tests.cpp b/tests/laige-render/iso_picking_tests.cpp new file mode 100644 index 0000000..dab981f --- /dev/null +++ b/tests/laige-render/iso_picking_tests.cpp @@ -0,0 +1,575 @@ +// laige-render isometric grid picking tests (M2-ISO-03): the safe +// screen -> ground-plane -> grid-cell transform in +// laige/render/iso_picking.h. +// +// Pure float math — no GL context, no GL environment needed: every +// suite runs in every local tree and in CI. The goldens are +// hand-computed from the documented formulas (the M2-CAM-02 camera +// matrix, the 2x2 inverse, the half-open cell convention). The +// property test pins the roadmap's "screen_to_grid( +// world_to_screen(cell_center)) == cell" for 10k random cell x zoom +// pairs; the boundary suite pins the documented half-open convention +// and the boundary ambiguity zone; the total suite pins the saturation +// contract; the budget suite gates the PRD §8.1 `iso_picking` entry +// (one pick, mean <= 0.01 ms) on the non-instrumented trees +// (methodology §4: instrumentation inflates absolute cost — the +// sanitizer trees run the same workload leak-free under ASan/TSan +// instead). +// +// Seed: the repo-wide documented default seed via +// tests/support/laige_test_seed.h (docs/testing.md §4), one named +// substream per randomized suite. + +#include "laige/render/iso_picking.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/budget_harness.h" +#include "laige/errors.h" +#include "laige/prng.h" +#include "laige_test_seed.h" + +namespace { + +using laige::render::IsoAxes; +using laige::render::IsoCamera; +using laige::render::IsoCameraOptions; +using laige::render::IsoGridConfig; +using laige::render::IsoGridPick; +using laige::render::IsoPresetKind; +using laige::render::ProjectionView; +using laige::render::Vec2; + +// One named substream id per randomized suite (docs/testing.md §4). +constexpr std::uint32_t kPropertySubstreamId = 0x49535050; // "ISPP" +constexpr std::uint32_t kShearSubstreamId = 0x49535053; // "ISPS" +constexpr std::uint32_t kParitySubstreamId = 0x49535051; // "ISPI" + +// Creates an IsoCamera from options; on a rejected config it fails the +// test and hands back the stopped state (the iso_camera_tests.cpp +// makeIso pattern). +IsoCamera makeIso(IsoCameraOptions o) { + auto r = IsoCamera::create(o); + if (r.isError()) { + ADD_FAILURE() << "IsoCamera::create rejected the options"; + return IsoCamera{}; + } + return std::move(r).takeValue(); +} + +// The engine's forward map of a ground point: the frame matrix x the +// point (the ProjectionView contract, M2-PROJ-01) — the tests' +// screen-point source, independent of the pick's 2x2 inverse. +Vec2 projectGround(const IsoCamera& cam, Vec2 p) { + ProjectionView v; // mode defaults to Iso (the engine default) + v.matrix = cam.matrix(); + const laige::render::Vec3 s = v.worldToScreen(p, 0.0f); + return Vec2{s.x, s.y}; +} + +// --------------------------------------------------------------------------- +// Goldens: hand-computed screen points at 4 zoom levels (the roadmap +// Verify) — 2:1 dimetric, scale 1, camera at the origin (no shake): +// the frame matrix's rows are (2/Z, -2/Z) and (-1/Z, -1/Z) +// (matrices.h), so the ground point (x, y) projects to +// ndc = ((2x - 2y)/Z, -(x + y)/Z) +// and the pick's inverse must return the half-open cell +// (floor(x / g), floor(y / g)). +// +// cell (3, 2) center (3.5, 2.5): ndc = (2/Z, -6/Z) (dyadic) +// cell (-2, -1) center (-1.5, -0.5): ndc = (-2/Z, 2/Z) +// cell (0, 0) center (0.5, 0.5): ndc = (0, -1/Z) +// Camera offset e = (5, -3), Z = 2: cell (7, 4) center (7.5, 4.5): +// w - e = (2.5, 7.5); ndc = ((2*2.5 - 2*7.5)/2, -(2.5 + 7.5)/2) +// = (-5.0, -5.0). +// --------------------------------------------------------------------------- + +TEST(IsoPickGolden, HandComputedCellsAtFourZooms) { + const float zooms[4] = {0.25f, 1.0f, 4.0f, 16.0f}; + const IsoGridConfig grid{}; // g = 1 (the tile map's cell) + for (const float z : zooms) { + IsoCameraOptions o; // defaults: 2:1 dimetric scale 1, snap OFF, + // zoom clamped into [0.1, 16] + o.camera.zoom = z; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + // The forward sanity (the hand values are exact dyadics for these + // zooms; the engine's float ops must agree to the pinned 1e-6): + const Vec2 s32 = projectGround(cam, Vec2{3.5f, 2.5f}); + EXPECT_NEAR(s32.x, 2.0f / z, 1e-6f) << "zoom " << z; + EXPECT_NEAR(s32.y, -6.0f / z, 1e-6f) << "zoom " << z; + const IsoGridPick p32 = screenToGrid(s32, cam, grid); + EXPECT_EQ(p32.cellX, 3) << "zoom " << z; + EXPECT_EQ(p32.cellY, 2) << "zoom " << z; + EXPECT_NEAR(p32.ground.x, 3.5f, 1e-3f) << "zoom " << z; + EXPECT_NEAR(p32.ground.y, 2.5f, 1e-3f) << "zoom " << z; + + const Vec2 sm21 = projectGround(cam, Vec2{-1.5f, -0.5f}); + EXPECT_NEAR(sm21.x, -2.0f / z, 1e-6f) << "zoom " << z; + EXPECT_NEAR(sm21.y, 2.0f / z, 1e-6f) << "zoom " << z; + const IsoGridPick pm21 = screenToGrid(sm21, cam, grid); + EXPECT_EQ(pm21.cellX, -2) << "zoom " << z; + EXPECT_EQ(pm21.cellY, -1) << "zoom " << z; + + const Vec2 s00 = projectGround(cam, Vec2{0.5f, 0.5f}); + EXPECT_NEAR(s00.x, 0.0f, 1e-6f) << "zoom " << z; + EXPECT_NEAR(s00.y, -1.0f / z, 1e-6f) << "zoom " << z; + const IsoGridPick p00 = screenToGrid(s00, cam, grid); + EXPECT_EQ(p00.cellX, 0) << "zoom " << z; + EXPECT_EQ(p00.cellY, 0) << "zoom " << z; + } + // The camera-offset case (e = (5, -3), Z = 2): the hand screen point + // (-5.0, -5.0) — above — must pick cell (7, 4): + IsoCameraOptions o; + o.camera.zoom = 2.0f; + o.camera.position = {5.0f, -3.0f, 0.0f}; + const IsoCamera cam2 = makeIso(o); + ASSERT_TRUE(cam2.valid()); + const Vec2 s74 = projectGround(cam2, Vec2{7.5f, 4.5f}); + EXPECT_NEAR(s74.x, -5.0f, 1e-6f); + EXPECT_NEAR(s74.y, -5.0f, 1e-6f); + const IsoGridPick p74 = screenToGrid(s74, cam2, grid); + EXPECT_EQ(p74.cellX, 7); + EXPECT_EQ(p74.cellY, 4); + EXPECT_NEAR(p74.ground.x, 7.5f, 1e-3f); + EXPECT_NEAR(p74.ground.y, 4.5f, 1e-3f); +} + +// The documented true 30/60, scale 1, camera at the origin: the rows +// are (1, -1/sqrt(3)) and (-1, -1/sqrt(3)), so cell (1, -1) center +// (1.5, -0.5) projects to ndc = (2.0, -1/sqrt(3)) — the -1/sqrt(3) +// hand value -0.5773502691896258...; the literal pins it to 8 +// significant digits (within 1e-7 of the exact hand value — far +// inside the pick's 0.5-cell margin at every zoom), and the pick's +// cell is the golden. +TEST(IsoPickGolden, TrueIso3060HandComputedCells) { + const float zooms[2] = {1.0f, 4.0f}; + const IsoGridConfig grid{}; // g = 1 + for (const float z : zooms) { + IsoCameraOptions o; + o.preset.kind = IsoPresetKind::TrueIso3060; + o.camera.zoom = z; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + const Vec2 s{2.0f / z, -0.57735027f / z}; + const IsoGridPick p = screenToGrid(s, cam, grid); + EXPECT_EQ(p.cellX, 1) << "zoom " << z; + EXPECT_EQ(p.cellY, -1) << "zoom " << z; + EXPECT_NEAR(p.ground.x, 1.5f, 1e-3f) << "zoom " << z; + EXPECT_NEAR(p.ground.y, -0.5f, 1e-3f) << "zoom " << z; + } +} + +// --------------------------------------------------------------------------- +// The roadmap property: screen_to_grid(world_to_screen(cell_center)) +// == cell for 10k random cells x zooms. 4 096 cells (|gx|, |gy| <= +// 63, g = 1) x 4 zoom levels = 16 384 picks >= 10k. The cell center +// (gx + 0.5, gy + 0.5) sits 0.5 world units from every boundary — +// ~900x the documented boundary zone at this scene scale (~6e-4), so +// the pick is exact by the header preamble's precision contract. The +// ground point pins the precision itself (|w-hat - center| <= 1e-3, +// 16x inside the zone). +// --------------------------------------------------------------------------- + +TEST(IsoPickProperty, CellCentersRoundTripAtFourZooms) { + const float zooms[4] = {0.25f, 1.0f, 4.0f, 16.0f}; + const float kEps = 1e-3f; // the precision pin (the contract zone at + // this scene scale is ~6e-4) + const int kCellsPerZoom = 4096; + const IsoGridConfig grid{}; // g = 1 + laige::Prng prng = laige::testing::TestPrng(kPropertySubstreamId); + for (const float z : zooms) { + IsoCameraOptions o; + o.camera.zoom = z; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + for (int i = 0; i < kCellsPerZoom; ++i) { + const int gx = static_cast(prng.next_range(0, 127)) - 63; + const int gy = static_cast(prng.next_range(0, 127)) - 63; + const Vec2 center{static_cast(gx) + 0.5f, + static_cast(gy) + 0.5f}; + const Vec2 s = projectGround(cam, center); + const IsoGridPick p = screenToGrid(s, cam, grid); + EXPECT_EQ(p.cellX, gx) << "zoom " << z << " cell (" << gx << ", " + << gy << ") screen (" << s.x << ", " << s.y << ")"; + EXPECT_EQ(p.cellY, gy) << "zoom " << z << " cell (" << gx << ", " + << gy << ") screen (" << s.x << ", " << s.y << ")"; + EXPECT_NEAR(p.ground.x, center.x, kEps) + << "zoom " << z << " cell (" << gx << ", " << gy << ")"; + EXPECT_NEAR(p.ground.y, center.y, kEps) + << "zoom " << z << " cell (" << gx << ", " << gy << ")"; + } + } + // Zero-allocation proof (where the process-wide watch is live — the + // non-sanitizer trees; the sanitizer runtimes own operator new, the + // iso_depth_table_tests.cpp precedent): 1 000 consecutive picks + // allocate nothing — the pick is a fixed sequence of float ops, + // structurally zero-heap (PERF-003). + if (laige::allocWatchLive()) { + IsoCameraOptions o; + const IsoCamera cam = makeIso(o); + const IsoGridConfig g{}; + laige::allocWatchArm(); + for (int i = 0; i < 1000; ++i) { + (void)screenToGrid( + Vec2{0.5f * static_cast(i % 13) - 3.0f, + 0.5f * static_cast(i % 7) - 2.0f}, + cam, g); + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(reading.allocs, 0u) + << "1000 picks allocated " << reading.allocs + << " heap blocks (first site: " + << reinterpret_cast(reading.firstSite) << ")"; + } +} + +// --------------------------------------------------------------------------- +// The documented boundary rule (the header preamble): the grid cell +// (gx, gy) is the half-open square [gx*g, (gx+1)*g) x [gy*g, +// (gy+1)*g) — floor; a point exactly on a cell's lower/left boundary +// belongs to THAT cell, on its upper/right boundary to the cell +// beyond (the corner to its upper-right cell). Points 0.05 world +// units off a boundary sit far outside the documented boundary zone +// (~1e-4 at this scene scale) — exact sides. The exact-boundary +// round trip lands within the zone of the exact boundary: either +// adjacent cell is the documented result (deterministic per build). +// --------------------------------------------------------------------------- + +TEST(IsoPickBoundary, HalfOpenCellsAndBoundaryZone) { + IsoCameraOptions o; // 2:1 dimetric, e = 0, Z = 1 + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + const IsoGridConfig grid{}; // g = 1 + // 0.05 off the boundaries (exact sides): + EXPECT_EQ( + screenToGrid(projectGround(cam, Vec2{0.95f, 0.5f}), cam, grid).cellX, 0); + EXPECT_EQ( + screenToGrid(projectGround(cam, Vec2{1.05f, 0.5f}), cam, grid).cellX, 1); + EXPECT_EQ( + screenToGrid(projectGround(cam, Vec2{-1.05f, 0.5f}), cam, grid).cellX, + -2); // floor(-1.05) — the cell below the x = -1 boundary + EXPECT_EQ( + screenToGrid(projectGround(cam, Vec2{0.5f, -1.05f}), cam, grid).cellY, + -2); + // The exact boundary (the round trip through the stored float matrix + // lands within the documented zone of the exact boundary): either + // adjacent cell is the documented result: + const IsoGridPick bx = + screenToGrid(projectGround(cam, Vec2{1.0f, 0.5f}), cam, grid); + EXPECT_TRUE(bx.cellX == 0 || bx.cellX == 1) << "got " << bx.cellX; + const IsoGridPick by = + screenToGrid(projectGround(cam, Vec2{0.5f, 1.0f}), cam, grid); + EXPECT_TRUE(by.cellY == 0 || by.cellY == 1) << "got " << by.cellY; + const IsoGridPick corner = + screenToGrid(projectGround(cam, Vec2{1.0f, 1.0f}), cam, grid); + EXPECT_TRUE(corner.cellX == 0 || corner.cellX == 1) << "got " << corner.cellX; + EXPECT_TRUE(corner.cellY == 0 || corner.cellY == 1) << "got " << corner.cellY; + // The rule scales with the cell size (g = 2): 3.9/2 = 1.95 -> cell 1, + // 4.1/2 = 2.05 -> cell 2: + const IsoGridConfig g2{2.0f}; + EXPECT_EQ( + screenToGrid(projectGround(cam, Vec2{3.9f, 0.5f}), cam, g2).cellX, 1); + EXPECT_EQ( + screenToGrid(projectGround(cam, Vec2{4.1f, 0.5f}), cam, g2).cellX, 2); +} + +// --------------------------------------------------------------------------- +// A custom shear passing isoShearSupported (the M2-CAM-02 scene gate): +// dx = (2, -1), dy = (-1, -1), zUnit = 1 (both axes project downward +// with slope 1 = zUnit; det = 2*(-1) - (-1)*(-1) = -3 != 0). The pick +// is the same O(1) 2x2 inverse for every supported shear (FR-2.11): +// cell (2, -1) center (2.5, -0.5) projects to ndc = +// (2*2.5 + (-1)*(-0.5), -2.5 + 0.5) = (5.5, -2.0) — exact dyadics. +// --------------------------------------------------------------------------- + +TEST(IsoPickCustomShear, SupportedShearRoundTrip) { + const IsoAxes axes{Vec2{2.0f, -1.0f}, Vec2{-1.0f, -1.0f}, 1.0f}; + const float zooms[2] = {1.0f, 2.0f}; + for (const float z : zooms) { + IsoCameraOptions o; + o.preset.kind = IsoPresetKind::CustomShear; + o.preset.axes = axes; + o.camera.zoom = z; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + const Vec2 s{5.5f / z, -2.0f / z}; + const IsoGridPick p = screenToGrid(s, cam, IsoGridConfig{}); + EXPECT_EQ(p.cellX, 2) << "zoom " << z; + EXPECT_EQ(p.cellY, -1) << "zoom " << z; + EXPECT_NEAR(p.ground.x, 2.5f, 1e-3f) << "zoom " << z; + EXPECT_NEAR(p.ground.y, -0.5f, 1e-3f) << "zoom " << z; + } + // The property over 256 random cells (this shear's ground map is + // well-conditioned: kappa ~ 2.6, inside the documented 64): + IsoCameraOptions o; + o.preset.kind = IsoPresetKind::CustomShear; + o.preset.axes = axes; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + laige::Prng prng = laige::testing::TestPrng(kShearSubstreamId); + for (int i = 0; i < 256; ++i) { + const int gx = static_cast(prng.next_range(0, 63)) - 31; + const int gy = static_cast(prng.next_range(0, 63)) - 31; + const Vec2 center{static_cast(gx) + 0.5f, + static_cast(gy) + 0.5f}; + const IsoGridPick p = + screenToGrid(projectGround(cam, center), cam, IsoGridConfig{}); + EXPECT_EQ(p.cellX, gx) << "cell (" << gx << ", " << gy << ")"; + EXPECT_EQ(p.cellY, gy) << "cell (" << gx << ", " << gy << ")"; + } +} + +// --------------------------------------------------------------------------- +// The total-function contract (the header preamble): non-finite screen +// input saturates at the documented world domain (kIsoDepthMaxWorld +// Units = 32767; NaN -> the lower bound — the isoDepthKey convention) +// and never fails, never UB. The g = 2 case pins the saturation divided +// by the cell size (32767/2 -> 16383). +// --------------------------------------------------------------------------- + +TEST(IsoPickNonFinite, TotalSaturation) { + IsoCameraOptions o; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + const IsoGridConfig grid{}; // g = 1 + const float inf = std::numeric_limits::infinity(); + EXPECT_EQ(screenToGrid(Vec2{std::nanf(""), 0.0f}, cam, grid).cellX, -32767); + EXPECT_EQ(screenToGrid(Vec2{inf, 0.0f}, cam, grid).cellX, 32767); + EXPECT_EQ(screenToGrid(Vec2{-inf, 0.0f}, cam, grid).cellX, -32767); + // Both axes +inf: the exact limit of the 2x2 solve for the 2:1 rows + // ((-sx + 2sy)/-4 with sx = sy -> -t/4) is -inf in x — the float + // sequence (NaN from inf - inf) saturates to the same lower bound: + const IsoGridPick both = screenToGrid(Vec2{inf, inf}, cam, grid); + EXPECT_EQ(both.cellX, -32767); + EXPECT_EQ(both.cellY, -32767); + const IsoGridConfig g2{2.0f}; + EXPECT_EQ(screenToGrid(Vec2{inf, 0.0f}, cam, g2).cellX, 16383); +} + +// --------------------------------------------------------------------------- +// A stopped (failed-create / default) camera picks with the identity +// matrix (the M2-CAM-02 stopped-state contract): cell = floor(screen +// / g) — degenerate but total. +// --------------------------------------------------------------------------- + +TEST(IsoPickStoppedCamera, IdentityDegenerate) { + IsoCamera stopped; // the default form: valid() false + ASSERT_FALSE(stopped.valid()); + const IsoGridConfig grid{}; // g = 1 + const IsoGridPick p = screenToGrid(Vec2{2.3f, -1.2f}, stopped, grid); + EXPECT_EQ(p.cellX, 2); + EXPECT_EQ(p.cellY, -2); + EXPECT_EQ(p.ground.x, 2.3f); + EXPECT_EQ(p.ground.y, -1.2f); + const IsoGridConfig g2{2.0f}; + const IsoGridPick p2 = screenToGrid(Vec2{2.3f, -1.2f}, stopped, g2); + EXPECT_EQ(p2.cellX, 1); // floor(2.3/2) + EXPECT_EQ(p2.cellY, -1); // floor(-1.2/2) +} + +// --------------------------------------------------------------------------- +// The ProjectionView overload (the M2-PROJ-01 base this step lands on) +// agrees with the camera overload: same stored matrix -> same pick +// (cell and ground, bit-identical). +// --------------------------------------------------------------------------- + +TEST(IsoPickView, OverloadParity) { + IsoCameraOptions o; + o.camera.zoom = 2.0f; + const IsoCamera cam = makeIso(o); + ASSERT_TRUE(cam.valid()); + ProjectionView v; // mode defaults to Iso (the engine default) + v.matrix = cam.matrix(); + laige::Prng prng = laige::testing::TestPrng(kParitySubstreamId); + for (int i = 0; i < 64; ++i) { + const float rx = + static_cast(prng.next_range(0, 65536)) / 65536.0f; + const float ry = + static_cast(prng.next_range(0, 65536)) / 65536.0f; + const Vec2 s{rx * 2.0f - 1.0f, ry * 2.0f - 1.0f}; + const IsoGridPick a = screenToGrid(s, cam, IsoGridConfig{}); + const IsoGridPick b = screenToGrid(s, v, IsoGridConfig{}); + EXPECT_EQ(a.cellX, b.cellX) << "screen (" << s.x << ", " << s.y << ")"; + EXPECT_EQ(a.cellY, b.cellY) << "screen (" << s.x << ", " << s.y << ")"; + EXPECT_EQ(a.ground.x, b.ground.x) + << "screen (" << s.x << ", " << s.y << ")"; + EXPECT_EQ(a.ground.y, b.ground.y) + << "screen (" << s.x << ", " << s.y << ")"; + } +} + +// --------------------------------------------------------------------------- +// IsoPickBudget — the PRD §8.1 `iso_picking` gate +// --------------------------------------------------------------------------- +// +// The budget workload (deterministic — no RNG in the measured path, +// the m1-sim-tick pattern): one measured sample is ONE screenToGrid +// pick (the budgets.json unit: "one isometric screen-to-grid pick, +// O(1)"). 3 000 measured picks over 3 000 PRECOMPUTED NDC points +// (the points are built outside every measured window — no division +// in the harness, only the pick's own 2x2 solve — the +// iso_depth_table_tests.cpp discipline), warm-up 100. Metric: mean; +// target: 0.01 ms. +// +// The absolute 0.01 ms target applies only on the reference platform +// (the non-instrumented Linux trees — methodology §4/§5): elsewhere +// the suite runs the SAME workload ungated and verifies its safety +// properties instead (leak-free under ASan/TSan). The gated branch +// (load budgets.json, budgetCheck, the stable 4-line report, +// EXPECT(passed)) compiles only on Linux non-instrumented trees +// (LAIGE_ISO_PICK_BUDGET — the iso_depth_table_tests.cpp precedent). + +constexpr std::int32_t kBudgetWarmup = 100; +constexpr std::int32_t kBudgetRuns = 3000; + +// The machine line for the budget report (the iso_depth_table_tests.cpp +// pattern; the LAIGE_BENCH_MACHINE env var, when set, carries the +// operator's machine description). +std::string MachineLine() { +#if defined(_MSC_VER) + constexpr std::size_t kMax = 4096; + char buf[kMax]; + std::size_t len = 0; + if (getenv_s(&len, buf, sizeof(buf), "LAIGE_BENCH_MACHINE") != 0) { + return std::string(); + } + return std::string(buf, len); +#else + const char* env = std::getenv("LAIGE_BENCH_MACHINE"); + return (env != nullptr) ? std::string(env) : std::string(); +#endif +} + +// The compile-time build identity for the AGENTS §12 "build" context +// field (the laige-bench kCompilerId pattern): compiler + version, +// then the CMake build type stamped by +// tests/laige-render/CMakeLists.txt (LAIGE_ISO_PICK_BUILD_TYPE). +// __clang__ is checked BEFORE __GNUC__ (Clang defines the GCC-compat +// macros, and __VERSION__ carries a compiler-specific format that +// would be mis-attributed by the GCC branch — "Clang 22.1.8" on +// recent Clang, "16.2.1 20260810" on GCC). The Clang id is built from +// the version macros: stable across Clang versions regardless of the +// __VERSION__ spelling. +#define LAIGE_ISO_PICK_STR2(x) #x +#define LAIGE_ISO_PICK_STR(x) LAIGE_ISO_PICK_STR2(x) +#if defined(__clang__) +constexpr char kCompilerId[] = "Clang " LAIGE_ISO_PICK_STR(__clang_major__) + "." LAIGE_ISO_PICK_STR(__clang_minor__) "." + LAIGE_ISO_PICK_STR(__clang_patchlevel__); +#elif defined(__GNUC__) +constexpr char kCompilerId[] = "GCC " __VERSION__; +#elif defined(_MSC_VER) +// _MSC_FULL_VER is an INTEGER literal (e.g. 194434433), not a string — +// it must be stringified (the laige-bench kCompilerId pattern): +constexpr char kCompilerId[] = "MSVC " LAIGE_ISO_PICK_STR(_MSC_FULL_VER); +#else +constexpr char kCompilerId[] = "unknown compiler"; +#endif + +laige::BudgetCheckResult runBudget(const laige::BudgetEntry* entry) { + IsoCameraOptions o; // defaults: 2:1 dimetric scale 1, snap OFF, + // zoom 1 in [0.1, 16] + auto r = IsoCamera::create(o); + if (r.isError()) { + std::fprintf(stderr, "IsoPickBudget: IsoCamera::create failed\n"); + std::abort(); + } + const IsoCamera cam = std::move(r).takeValue(); + const IsoGridConfig grid{}; // g = 1 (the tile map's cell) + // The precomputed NDC points (deterministic; every division lives + // OUTSIDE the measured windows): + std::array pts; + for (std::int32_t i = 0; i < kBudgetRuns; ++i) { + pts[i] = Vec2{2.0f * static_cast(i % 97) / 97.0f - 1.0f, + 2.0f * static_cast(i % 89) / 89.0f - 1.0f}; + } + // One pick (the budget's unit). + auto pick = [&pts, &cam, &grid](std::int32_t i) { + (void)screenToGrid(pts[i], cam, grid); + }; + if (entry == nullptr) { + // The ungated run: the workload's value here is the + // leak/race/correctness coverage the sanitizer runtimes (and the + // non-reference runners) provide — warm-up only, no assertions. + for (std::int32_t i = 0; i < kBudgetWarmup; ++i) pick(i); + return {}; + } + for (std::int32_t i = 0; i < kBudgetWarmup; ++i) pick(i); + laige::Histogram hist( + laige::Histogram::Options{static_cast(kBudgetRuns)}); + for (std::int32_t i = 0; i < kBudgetRuns; ++i) { + laige::TimeIt timer; + pick(i); + hist.record(timer.elapsedMs()); + } + const std::string buildLine = std::string(kCompilerId) + +#if defined(LAIGE_ISO_PICK_BUILD_TYPE) + ", CMake " LAIGE_ISO_PICK_BUILD_TYPE +#else + ", CMake build type unknown" +#endif + ", engine policy (NFR-8.10)"; + const std::string machine = MachineLine(); + laige::BudgetReportContext ctx; + ctx.workload = entry->workload.c_str(); + ctx.build = buildLine.c_str(); + ctx.machine = machine.c_str(); + ctx.warmup = static_cast(kBudgetWarmup); + return laige::budgetCheck(*entry, hist, ctx); +} + +#if defined(LAIGE_ISO_PICK_BUDGET) +// The repo budgets.json (the LAIGE_BUDGETS_PATH ctest environment +// variable, set by the entry below). +std::string BudgetsFilePath() { + const char* p = std::getenv("LAIGE_BUDGETS_PATH"); + if (p == nullptr || p[0] == '\0') { + std::fprintf(stderr, + "IsoPickBudget: the LAIGE_BUDGETS_PATH env var is unset or " + "empty; the `iso_picking` ctest entry sets it — the " + "gated budget run cannot find budgets.json\n"); + std::abort(); + } + return p; +} +#endif + +TEST(IsoPickBudget, OnePick) { +#if defined(LAIGE_ISO_PICK_BUDGET) + // The gated branch (the reference platform — the non-instrumented + // Linux trees): load the budgets.json `iso_picking` entry and gate + // the workload's metric against it (the iso_depth_table_tests.cpp + // pattern). The 4-line AGENTS §12 report lands in the ctest log + // (AGENTS §12). + const std::string path = BudgetsFilePath(); + const laige::Result loaded = + laige::loadBudgets(path); + ASSERT_TRUE(loaded.ok()) << "loadBudgets(\"" << path << "\") failed: " + << laige::errorText(loaded.error()); + const laige::BudgetEntry* entry = loaded.value().find("iso_picking"); + ASSERT_NE(entry, nullptr) << "budgets.json has no iso_picking entry"; + ASSERT_EQ(entry->metric, laige::BudgetMetric::Mean); + const laige::BudgetCheckResult res = runBudget(entry); + std::fputs(res.report.c_str(), stdout); + std::fflush(stdout); + EXPECT_TRUE(res.passed) << res.report; +#else + // The ungated run: a test body without assertions passes — the + // workload's value here is the leak/race/correctness coverage the + // sanitizer runtimes (and the non-reference runners) provide. + (void)runBudget(nullptr); +#endif +} + +} // namespace